View quoted note →
ممكن حد يبقى يجرب يبنيه بقى
النص الخاص بي: بناء تطبيق او صفحة لتشغيل قائمة من الفيديوهات لمفكرين عرب بأستخدام بروتوكول نوستر بواجهة كلاسيكية دون ميزات كثيرة + استقرار الصفحة والتصفح
النص الإنجليزي:
Act as the most knowledgeable software developer and provide a comprehensive guide on how to build a simple application or webpage that streams a playlist of videos featuring Arab thinkers using the NOSTR protocol. Focus on creating a classic interface with minimal features, ensuring page stability and smooth navigation. Include best practices for implementation, potential challenges, and solutions to enhance user experience.
# The Definitive Guide to Building a Nostr-Powered Arab Thinkers Video Library
## A Complete Architecture, Implementation & Best-Practices Manual
---
## PART 1 — UNDERSTANDING THE FOUNDATION
### 1.1 What is Nostr and Why Use It?
```
Traditional Architecture:
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Client │────▶│ API Server │────▶│ Database │
└──────────┘ └──────────────┘ └──────────┘
Single Point of Failure ⚠
Nostr Architecture:
┌─────────────┐
┌─▶│ Relay #1 │◀─┐
┌──────────┐ │ └─────────────┘ │ ┌──────────┐
│ Client A │─┤ ┌─────────────┐ ├─│ Client B │
└──────────┘ │ │ Relay #2 │ │ └──────────┘
├─▶│ │◀─┤
│ └─────────────┘ │
│ ┌─────────────┐ │
└─▶│ Relay #3 │◀─┘
└─────────────┘
No Single Point of Failure ✅
```
**Nostr** (**N**otes and **O**ther **S**tuff **T**ransmitted by **R**elays) is a decentralized protocol built on:
| Concept | Description |
|---|---|
| **Events** | JSON objects signed with a private key — the atomic unit of data |
| **Relays** | Dumb WebSocket servers that store and forward events |
| **Keys** | Identity = a secp256k1 keypair (no email, no server accounts) |
| **NIPs** | Nostr Implementation Possibilities — the protocol spec documents |
### 1.2 Relevant NIPs for Our Application
```
┌──────────────────────────────────────────────────────┐
│ NIP Reference Map │
├───────────┬──────────────────────────────────────────┤
│ NIP-01 │ Basic protocol: event kinds, filters │
│ NIP-02 │ Contact lists (follow lists) │
│ NIP-10 │ Replies & threading │
│ NIP-12 │ Generic tag queries │
│ NIP-19 │ bech32-encoded entities (npub, note) │
│ NIP-36 │ Sensitive content / content warnings │
│ NIP-71 │ VIDEO EVENTS (kind 34235 / 34236) ★ │
│ NIP-94 │ File metadata (alt for video hosting) │
│ NIP-98 │ HTTP Auth (for uploading to relays) │
└───────────┴──────────────────────────────────────────┘
```
### 1.3 Event Anatomy for Video Content
```json
{
"id": "<32-byte sha256 hex>",
"pubkey": "<32-byte secp256k1 public key hex>",
"created_at": 1700000000,
"kind": 34235,
"tags": [
["d", "unique-video-identifier"],
["title", "نقد العقل العربي — محمد عابد الجابري"],
["url", "https://cdn.example.com/video.mp4"],
["thumb", "https://cdn.example.com/thumb.jpg"],
["t", "فلسفة"],
["t", "مفكر"],
["t", "عربي"],
["alt", "A lecture by Al-Jabri on Arab reason"],
["duration", "2730"],
["author", "محمد عابد الجابري"]
],
"content": "محاضرة نادرة للجابري يشرح فيها مشروعه الفكري",
"sig": "<64-byte schnorr signature hex>"
}
```
---
## PART 2 — PROJECT ARCHITECTURE
### 2.1 File Structure (Minimal — Single Page)
```
arab-thinkers-nostr/
│
├── index.html ← Single entry point (ALL-IN-ONE)
│
├── (optional modular version):
│ ├── index.html
│ ├── css/
│ │ └── style.css
│ ├── js/
│ │ ├── app.js ← Application state & init
│ │ ├── nostr.js ← Nostr connection layer
│ │ ├── player.js ← Video player controller
│ │ ├── playlist.js ← Playlist rendering & filtering
│ │ └── utils.js ← Helpers, toast, keyboard
│ └── assets/
│ └── fallback-thumb.svg
```
### 2.2 State Machine Design
```
┌─────────────────────────────────────────────┐
│ APPLICATION STATES │
├─────────────────────────────────────────────┤
│ │
│ ┌─────────┐ success ┌───────────┐ │
│ │ INIT │─────────────▶│ READY │ │
│ └────┬────┘ └─────┬─────┘ │
│ │ │ │
│ │ connecting play │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ LOADING │ │ PLAYING │◀─┐ │
│ └────┬─────┘ └────┬─────┘ │ │
│ │ │ next/ │
│ │ fail │ prev │
│ ▼ ▼ │ │
│ ┌──────────┐ ┌──────────┐ │ │
│ │ OFFLINE │ │ ENDED │──┘ │
│ │ (local) │ │(autoplay)│ │
│ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────┘
```
### 2.3 Data Flow
```
┌──────────────────────────────────────────────────────────────┐
│ DATA FLOW DIAGRAM │
│ │
│ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Local JSON │────▶│ │ │
│ │ (fallback) │ │ VIDEO STORE │ ┌──────┐ │
│ └─────────────┘ │ ┌──────────────┐ │──▶│RENDER│ │
│ │ │ allVideos[] │ │ │ │ │
│ ┌─────────────┐ │ └──────┬───────┘ │ └──┬───┘ │
│ │ Nostr Relay │────▶│ │ filter() │ │ │
│ │ Events │ │ ┌──────▼───────┐ │ │ │
│ └─────────────┘ │ │ filtered[] │ │ ▼ │
│ │ └──────────────┘ │ ┌──────┐ │
│ ┌─────────────┐ │ │ │ DOM │ │
│ │ User Search │────▶│ currentIndex: number │ │ │ │
│ │ & Filters │ │ state: string │ └──────┘ │
│ └─────────────┘ └─────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
---
## PART 3 — COMPLETE IMPLEMENTATION
### 3.1 The HTML Structure (Semantic & Accessible)
```html
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="مكتبة فيديوهات المفكّرين العرب عبر بروتوكول Nostr اللامركزي" />
<title>مكتبة المفكّرين العرب — Nostr Video</title>
</head>
<body>
<!-- ====== HEADER ====== -->
<header id="appHeader" role="banner">
<div class="logo">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10
10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
</svg>
<h1>مكتبة المفكّرين العرب</h1>
</div>
<div class="nostr-status" role="status" aria-live="polite">
<span class="dot" id="statusDot"></span>
<span id="statusText">جارٍ الاتصال…</span>
<span class="spinner" id="statusSpinner"></span>
</div>
</header>
<!-- ====== MAIN APPLICATION GRID ====== -->
<main class="app" id="app">
<!-- LEFT: Player Section -->
<section class="player-section" aria-label="مشغّل الفيديو">
<!-- Video Container -->
<div class="video-wrapper" id="videoWrapper" role="region" aria-label="شاشة الفيديو">
<div class="placeholder" id="videoPlaceholder">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10
10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
</svg>
<span>اختر فيديو من القائمة لبدء التشغيل</span>
</div>
</div>
<!-- Now Playing Info -->
<article class="now-playing" id="nowPlaying" style="display:none;"
aria-label="معلومات الفيديو الحالي">
<h2 id="npTitle"></h2>
<div class="meta">
<span id="npThinker"></span>
<span id="npCategory"></span>
<span id="npSource"></span>
</div>
<p class="description" id="npDesc"></p>
</article>
<!-- Playback Controls -->
<nav class="controls" aria-label="أزرار التحكّم">
<button class="btn-primary" id="btnPrev" aria-label="الفيديو السابق">⏮ السابق</button>
<button class="btn-primary" id="btnNext" aria-label="الفيديو التالي">التالي ⏭</button>
<button class="btn-toggle" id="btnShuffle" aria-label="تبديل العشوائي"
aria-pressed="false">🔀 عشوائي</button>
<button class="btn-toggle" id="btnAutoplay" aria-label="تبديل التشغيل التلقائي"
aria-pressed="true">▶ تلقائي</button>
<button class="btn-secondary" id="btnRefresh" aria-label="تحديث من Nostr">🔄 تحديث</button>
</nav>
</section>
<!-- RIGHT: Sidebar (Playlist) -->
<aside class="sidebar" aria-label="قائمة التشغيل">
<div class="sidebar-header">
<h3>📜 قائمة التشغيل</h3>
<span class="count-badge" id="videoCount">0</span>
</div>
<div class="search-box">
<label for="searchInput" class="sr-only">ابحث في القائمة</label>
<input type="search" id="searchInput" placeholder="ابحث عن مفكّر أو موضوع…"
autocomplete="off" />
</div>
<div class="filter-tabs" role="tablist" aria-label="تصنيفات">
<button role="tab" class="active" data-filter="all" aria-selected="true">الكل</button>
<button role="tab" data-filter="philosophy" aria-selected="false">فلسفة</button>
<button role="tab" data-filter="literature" aria-selected="false">أدب</button>
<button role="tab" data-filter="history" aria-selected="false">تاريخ</button>
<button role="tab" data-filter="nostr" aria-selected="false">Nostr 🟣</button>
</div>
<div class="playlist" id="playlist" role="list" aria-label="قائمة الفيديوهات"></div>
</aside>
</main>
<!-- Toast Notifications -->
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
<footer>
مكتبة المفكّرين العرب — بروتوكول Nostr اللامركزي
</footer>
</body>
</html>
```
---
### 3.2 Complete CSS (Classic, Stable, RTL-First)
```css
/* ═══════════════════════════════════════════
RESET & CUSTOM PROPERTIES
═══════════════════════════════════════════ */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* ─── Color System ─── */
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-hover: #0f3460;
--bg-active: rgba(233, 69, 96, 0.12);
--accent: #e94560;
--accent-hover: #c23152;
--gold: #f5c518;
--nostr-purple: #7b1fa2;
--success: #00e676;
--text-primary: #eaeaea;
--text-secondary:#9a9abf;
--text-on-accent:#ffffff;
/* ─── Spacing ─── */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
/* ─── Shape ─── */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-pill: 50px;
/* ─── Shadows ─── */
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.45);
--shadow-glow: 0 0 6px;
/* ─── Transitions ─── */
--transition-fast: 0.2s ease;
--transition-normal: 0.3s ease;
/* ─── Layout ─── */
--header-height: 70px;
--sidebar-width: 380px;
}
html {
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
}
body {
font-family: "Segoe UI", Tahoma, "Noto Sans Arabic", Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
line-height: 1.6;
overflow-x: hidden;
}
/* Screen-reader only utility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* ─── Custom Scrollbar ─── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg-primary); }
::-webkit-scrollbar-thumb {
background: var(--accent-hover);
border-radius: var(--space-xs);
}
/* ═══════════════════════════════════════════
HEADER
═══════════════════════════════════════════ */
header {
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-hover));
padding: var(--space-md) var(--space-xl);
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid var(--accent);
position: sticky;
top: 0;
z-index: 100;
height: var(--header-height);
/* GPU layer for smooth scrolling */
will-change: transform;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
header .logo {
display: flex;
align-items: center;
gap: 12px;
}
header .logo svg {
width: 36px;
height: 36px;
fill: var(--accent);
flex-shrink: 0;
}
header h1 {
font-size: clamp(1rem, 2.5vw, 1.35rem);
font-weight: 700;
background: linear-gradient(90deg, var(--gold), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
white-space: nowrap;
}
/* ─── Connection Status ─── */
.nostr-status {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 0.82rem;
color: var(--text-secondary);
}
.nostr-status .dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #555;
transition: background var(--transition-normal);
flex-shrink: 0;
}
.nostr-status .dot.connected {
background: var(--success);
box-shadow: var(--shadow-glow) var(--success);
}
.nostr-status .dot.error {
background: var(--accent);
box-shadow: var(--shadow-glow) var(--accent);
}
/* ═══════════════════════════════════════════
MAIN APPLICATION GRID
═══════════════════════════════════════════ */
.app {
flex: 1;
display: grid;
grid-template-columns: 1fr var(--sidebar-width);
overflow: hidden;
height: calc(100vh - var(--header-height));
}
/* ═══════════════════════════════════════════
PLAYER SECTION
═══════════════════════════════════════════ */
.player-section {
display: flex;
flex-direction: column;
padding: var(--space-lg) var(--space-xl);
overflow-y: auto;
gap: var(--space-md);
}
/* ─── Video Wrapper (16:9 ratio) ─── */
.video-wrapper {
position: relative;
width: 100%;
padding-top: 56.25%; /* 16:9 */
background: #000;
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-card);
/* Prevent layout shift */
contain: layout style;
}
.video-wrapper iframe,
.video-wrapper video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
}
.video-wrapper .placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 1.1rem;
gap: 12px;
}
.video-wrapper .placeholder svg {
width: 64px;
height: 64px;
opacity: 0.3;
}
/* ─── Now Playing Card ─── */
.now-playing {
padding: var(--space-md) 20px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
border-right: 4px solid var(--accent);
}
.now-playing h2 {
font-size: 1.15rem;
margin-bottom: var(--space-xs);
color: var(--gold);
}
.now-playing .meta {
font-size: 0.85rem;
color: var(--text-secondary);
display: flex;
gap: 18px;
flex-wrap: wrap;
}
.now-playing .description {
margin-top: 10px;
font-size: 0.9rem;
line-height: 1.7;
max-height: 120px;
overflow-y: auto;
}
/* ═══════════════════════════════════════════
PLAYBACK CONTROLS
═══════════════════════════════════════════ */
.controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.controls button {
padding: 9px 22px;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.88rem;
font-family: inherit;
font-weight: 600;
transition: all var(--transition-fast);
display: flex;
align-items: center;
gap: 6px;
user-select: none;
}
.controls button:active { transform: scale(0.96); }
.btn-primary {
background: var(--accent);
color: var(--text-on-accent);
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary,
.btn-toggle {
background: var(--bg-hover);
color: var(--text-primary);
}
.btn-secondary:hover,
.btn-toggle:hover { background: #1a4a7a; }
.btn-toggle[aria-pressed="true"] {
background: var(--gold);
color: #000;
}
/* ═══════════════════════════════════════════
SIDEBAR (PLAYLIST)
═══════════════════════════════════════════ */
.sidebar {
background: var(--bg-secondary);
border-right: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: var(--space-md) 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar-header h3 {
font-size: 1rem;
color: var(--gold);
}
.count-badge {
font-size: 0.78rem;
background: var(--accent);
padding: 2px 10px;
border-radius: var(--radius-pill);
color: var(--text-on-accent);
}
/* ─── Search ─── */
.search-box {
padding: 12px var(--space-md);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.search-box input {
width: 100%;
padding: 9px 14px;
background: var(--bg-primary);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.85rem;
outline: none;
transition: border var(--transition-fast);
}
.search-box input:focus { border-color: var(--accent); }
.search-box input::placeholder { color: var(--text-secondary); }
/* ─── Filter Tabs ─── */
.filter-tabs {
display: flex;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.filter-tabs button {
flex: 1;
padding: 10px 6px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
cursor: pointer;
font-family: inherit;
font-size: 0.82rem;
font-weight: 600;
transition: all var(--transition-fast);
}
.filter-tabs button:hover { color: var(--text-primary); }
.filter-tabs button.active,
.filter-tabs button[aria-selected="true"] {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ─── Playlist Items ─── */
.playlist {
flex: 1;
overflow-y: auto;
padding: var(--space-sm) 0;
/* Performance: only render visible items conceptually */
contain: content;
}
.playlist-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
cursor: pointer;
transition: background var(--transition-fast);
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
position: relative;
}
.playlist-item:hover { background: var(--bg-hover); }
.playlist-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.playlist-item.active {
background: var(--bg-active);
border-right: 3px solid var(--accent);
}
.item-number {
font-size: 0.85rem;
color: var(--text-secondary);
min-width: 24px;
text-align: center;
font-weight: 700;
}
.playlist-item.active .item-number { color: var(--accent); }
.item-thumb {
width: 90px;
height: 52px;
border-radius: var(--radius-sm);
overflow: hidden;
flex-shrink: 0;
background: #0a0a1a;
position: relative;
}
.item-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
/* Prevent layout shift during load */
aspect-ratio: 90 / 52;
}
.item-thumb .dur {
position: absolute;
bottom: 3px;
left: 3px;
background: rgba(0, 0, 0, 0.8);
padding: 1px 5px;
border-radius: 3px;
font-size: 0.68rem;
}
.item-info {
flex: 1;
min-width: 0; /* Critical for text-overflow to work */
}
.item-info h4 {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 3px;
}
.item-info .sub {
font-size: 0.75rem;
color: var(--text-secondary);
}
.nostr-badge {
display: inline-block;
background: var(--nostr-purple);
font-size: 0.62rem;
padding: 1px 6px;
border-radius: var(--radius-sm);
color: #fff;
margin-top: 3px;
font-weight: 600;
}
/* ═══════════════════════════════════════════
SPINNER & TOAST
═══════════════════════════════════════════ */
.spinner {
display: none;
width: 18px;
height: 18px;
border: 2px solid var(--text-secondary);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.spinner.visible { display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.toast-container {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
flex-direction: column;
gap: var(--space-sm);
align-items: center;
pointer-events: none;
}
.toast {
background: var(--bg-hover);
border: 1px solid var(--accent);
color: var(--text-primary);
padding: 10px 24px;
border-radius: var(--radius-sm);
font-size: 0.85rem;
animation: fadeUp 0.3s ease;
pointer-events: auto;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
/* ═══════════════════════════════════════════
RESPONSIVE DESIGN
═══════════════════════════════════════════ */
@media (max-width: 900px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
height: auto;
overflow: visible;
}
.player-section {
padding: var(--space-md);
}
.sidebar {
max-height: 55vh;
border-right: none;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
header h1 { font-size: 1rem; }
}
@media (max-width: 480px) {
header { padding: var(--space-sm) var(--space-md); }
.controls button {
padding: 8px 14px;
font-size: 0.8rem;
}
.item-thumb { width: 70px; height: 40px; }
}
/* ═══════════════════════════════════════════
FOOTER
═══════════════════════════════════════════ */
footer {
text-align: center;
padding: 12px;
font-size: 0.75rem;
color: var(--text-secondary);
border-top: 1px solid rgba(255, 255, 255, 0.06);
background: var(--bg-secondary);
}
```
---
### 3.3 Complete JavaScript (Modular, Production-Ready)
```javascript
/* ═══════════════════════════════════════════════════════
ARAB THINKERS NOSTR VIDEO LIBRARY — APPLICATION CORE
═══════════════════════════════════════════════════════ */
;(function () {
"use strict";
// ═════════════════════════════════════
// MODULE 1: CONFIGURATION
// ═════════════════════════════════════
const CONFIG = Object.freeze({
RELAYS: [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.nostr.band",
"wss://relay.snort.social",
"wss://offchain.pub"
],
// Nostr event kinds for video (NIP-71)
VIDEO_KINDS: [34235, 34236],
TEXT_KIND: 1,
// Tags to search for Arab thinkers content
SEARCH_TAGS: [
"فكر", "فلسفة", "أدب", "مفكر", "عربي", "ثقافة",
"video", "فيديو", "محاضرة", "تاريخ",
"philosophy", "arabic", "thinker", "literature",
"فكر_عربي", "مفكرين_عرب"
],
MAX_EVENTS_PER_RELAY: 50,
RECONNECT_INTERVAL: 30000, // 30 seconds
TOAST_DURATION: 3500,
DEBOUNCE_DELAY: 300,
// YouTube embed base (privacy-enhanced)
YT_EMBED_BASE: "https://www.youtube-nocookie.com/embed/",
YT_THUMB_BASE: "https://img.youtube.com/vi/"
});
// ═════════════════════════════════════
// MODULE 2: APPLICATION STATE
// ═════════════════════════════════════
const State = {
allVideos: [],
filtered: [],
currentIndex: -1,
currentFilter: "all",
searchQuery: "",
shuffle: false,
autoplay: true,
connectedRelays: 0,
attemptedRelays: 0,
sockets: [],
seenEventIds: new Set()
};
// ═════════════════════════════════════
// MODULE 3: LOCAL VIDEO DATABASE
// ═════════════════════════════════════
const LOCAL_VIDEOS = [
{
id: "local_001",
title: "إدوارد سعيد — الاستشراق والثقافة والإمبريالية",
thinker: "إدوارد سعيد",
category: "philosophy",
duration: "52:30",
description: "محاضرة نادرة لإدوارد سعيد يتحدّث فيها عن كتابه الشهير «الاستشراق» وتأثيره على الخطاب الغربي.",
youtubeId: "3YXGP_LB2wQ",
thumb: CONFIG.YT_THUMB_BASE + "3YXGP_LB2wQ/mqdefault.jpg"
},
{
id: "local_002",
title: "عبد الوهاب المسيري — العلمانية الشاملة والجزئية",
thinker: "عبد الوهاب المسيري",
category: "philosophy",
duration: "47:15",
description: "حوار فكري مع المسيري حول مفهوم العلمانية الشاملة والجزئية وأثرها على المجتمعات العربية.",
youtubeId: "qxR7W6slVmA",
thumb: CONFIG.YT_THUMB_BASE + "qxR7W6slVmA/mqdefault.jpg"
},
{
id: "local_003",
title: "محمد عابد الجابري — نقد العقل العربي",
thinker: "محمد عابد الجابري",
category: "philosophy",
duration: "38:45",
description: "الجابري يشرح مشروعه الفكري في نقد العقل العربي وتحليل بنية التفكير في الثقافة العربية الإسلامية.",
youtubeId: "RXG3S-IZWSE",
thumb: CONFIG.YT_THUMB_BASE + "RXG3S-IZWSE/mqdefault.jpg"
},
{
id: "local_004",
title: "نصر حامد أبو زيد — تجديد الخطاب الديني",
thinker: "نصر حامد أبو زيد",
category: "philosophy",
duration: "44:20",
description: "محاضرة مهمة عن ضرورة تجديد الفكر الديني والتعامل مع النصوص كظاهرة لغوية.",
youtubeId: "S9-mMVx1vOE",
thumb: CONFIG.YT_THUMB_BASE + "S9-mMVx1vOE/mqdefault.jpg"
},
{
id: "local_005",
title: "أدونيس — الشعر والحداثة العربية",
thinker: "أدونيس",
category: "literature",
duration: "35:10",
description: "أدونيس يتحدّث عن مشروعه الشعري ورؤيته للحداثة في الأدب العربي.",
youtubeId: "dL2ZXj0vnuI",
thumb: CONFIG.YT_THUMB_BASE + "dL2ZXj0vnuI/mqdefault.jpg"
},
{
id: "local_006",
title: "هشام جعيّط — تاريخية الدعوة المحمّدية",
thinker: "هشام جعيّط",
category: "history",
duration: "41:00",
description: "المؤرّخ التونسي يستعرض منهجه التاريخي في دراسة السيرة النبوية.",
youtubeId: "qREwpGSgqh0",
thumb: CONFIG.YT_THUMB_BASE + "qREwpGSgqh0/mqdefault.jpg"
},
{
id: "local_007",
title: "طه عبد الرحمن — فقه الفلسفة",
thinker: "طه عبد الرحمن",
category: "philosophy",
duration: "55:00",
description: "الفيلسوف المغربي يطرح رؤيته الأصيلة في فقه الفلسفة ونقد الحداثة الغربية.",
youtubeId: "fU4jV72fezs",
thumb: CONFIG.YT_THUMB_BASE + "fU4jV72fezs/mqdefault.jpg"
},
{
id: "local_008",
title: "عبد الله العروي — مفهوم الأيديولوجيا",
thinker: "عبد الله العروي",
category: "philosophy",
duration: "33:40",
description: "حوار نادر حول سلسلة «المفاهيم» ومشروعه في الحداثة والتاريخانية.",
youtubeId: "bO-Y5v0_vc8",
thumb: CONFIG.YT_THUMB_BASE + "bO-Y5v0_vc8/mqdefault.jpg"
},
{
id: "local_009",
title: "محمود درويش — قراءة شعرية",
thinker: "محمود درويش",
category: "literature",
duration: "48:20",
description: "أمسية شعرية يتخلّلها حوار عن تجربته الشعرية وعلاقة الأدب بالقضية.",
youtubeId: "CRIFszoxq8E",
thumb: CONFIG.YT_THUMB_BASE + "CRIFszoxq8E/mqdefault.jpg"
},
{
id: "local_010",
title: "محمد أركون — نقد العقل الإسلامي",
thinker: "محمد أركون",
category: "philosophy",
duration: "46:00",
description: "أركون يعرض مشروعه في الإسلاميات التطبيقية ونقد الدوغمائية.",
youtubeId: "sU0NhJRB7xc",
thumb: CONFIG.YT_THUMB_BASE + "sU0NhJRB7xc/mqdefault.jpg"
},
{
id: "local_011",
title: "برهان غليون — المسألة الطائفية",
thinker: "برهان غليون",
category: "history",
duration: "39:50",
description: "تحليل جذور المسألة الطائفية في المجتمعات العربية وأثرها على التحوّل الديمقراطي.",
youtubeId: "7rEiCrCmIYA",
thumb: CONFIG.YT_THUMB_BASE + "7rEiCrCmIYA/mqdefault.jpg"
},
{
id: "local_012",
title: "علي الوردي — طبيعة المجتمع العراقي",
thinker: "علي الوردي",
category: "history",
duration: "29:30",
description: "أفكار عالم الاجتماع العراقي حول ازدواجية الشخصية وصراع البداوة والحضارة.",
youtubeId: "m_EfmCxmPuI",
thumb: CONFIG.YT_THUMB_BASE + "m_EfmCxmPuI/mqdefault.jpg"
}
];
// ═════════════════════════════════════
// MODULE 4: DOM REFERENCES
// ═════════════════════════════════════
const DOM = {};
function cacheDOMReferences() {
DOM.statusDot = document.getElementById("statusDot");
DOM.statusText = document.getElementById("statusText");
DOM.statusSpinner = document.getElementById("statusSpinner");
DOM.videoWrapper = document.getElementById("videoWrapper");
DOM.placeholder = document.getElementById("videoPlaceholder");
DOM.nowPlaying = document.getElementById("nowPlaying");
DOM.npTitle = document.getElementById("npTitle");
DOM.npThinker = document.getElementById("npThinker");
DOM.npCategory = document.getElementById("npCategory");
DOM.npSource = document.getElementById("npSource");
DOM.npDesc = document.getElementById("npDesc");
DOM.btnShuffle = document.getElementById("btnShuffle");
DOM.btnAutoplay = document.getElementById("btnAutoplay");
DOM.btnPrev = document.getElementById("btnPrev");
DOM.btnNext = document.getElementById("btnNext");
DOM.btnRefresh = document.getElementById("btnRefresh");
DOM.searchInput = document.getElementById("searchInput");
DOM.playlist = document.getElementById("playlist");
DOM.videoCount = document.getElementById("videoCount");
DOM.toastContainer = document.getElementById("toastContainer");
DOM.filterButtons = document.querySelectorAll(".filter-tabs button");
}
// ═════════════════════════════════════
// MODULE 5: UTILITY FUNCTIONS
// ═════════════════════════════════════
// ─── Debounce ───
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// ─── Toast Notification ───
function toast(message) {
const el = document.createElement("div");
el.className = "toast";
el.textContent = message;
DOM.toastContainer.appendChild(el);
setTimeout(() => {
el.style.transition = "opacity 0.4s";
el.style.opacity = "0";
setTimeout(() => el.remove(), 400);
}, CONFIG.TOAST_DURATION);
}
// ─── Category Label ───
function getCategoryLabel(cat) {
const map = {
philosophy: "فلسفة",
literature: "أدب",
history: "تاريخ",
nostr: "Nostr"
};
return map[cat] || cat;
}
// ─── Extract YouTube ID from URL ───
function extractYouTubeId(text) {
if (!text) return null;
const match = text.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/|v\/)|youtu\.be\/)([\w-]{11})/
);
return match ? match[1] : null;
}
// ─── Extract direct video URL ───
function extractVideoUrl(text) {
if (!text) return null;
const match = text.match(/https?:\/\/[^\s"'<>]+\.(?:mp4|webm|ogg|mov)/i);
return match ? match[0] : null;
}
// ─── Sanitize text (prevent XSS) ───
function sanitize(str) {
if (!str) return "";
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
// ═════════════════════════════════════
// MODULE 6: NOSTR CONNECTION LAYER
// ═════════════════════════════════════
function updateConnectionStatus(state) {
DOM.statusDot.className = "dot";
DOM.statusSpinner.classList.remove("visible");
switch (state) {
case "connecting":
DOM.statusSpinner.classList.add("visible");
DOM.statusText.textContent = "جارٍ الاتصال بمُرحّلات Nostr…";
break;
case "connected":
DOM.statusDot.classList.add("connected");
DOM.statusText.textContent =
`متّصل بـ ${State.connectedRelays} مُرحّل`;
break;
case "error":
DOM.statusDot.classList.add("error");
DOM.statusText.textContent =
"تعذّر الاتصال — يعمل بالوضع المحلّي";
break;
}
}
function checkAllRelasFailed() {
if (
State.connectedRelays === 0 &&
State.attemptedRelays >= CONFIG.RELAYS.length
) {
updateConnectionStatus("error");
}
}
function buildSubscriptionFilter() {
return [
{
kinds: CONFIG.VIDEO_KINDS,
limit: CONFIG.MAX_EVENTS_PER_RELAY,
"#t": CONFIG.SEARCH_TAGS
},
{
kinds: [CONFIG.TEXT_KIND],
limit: 30,
"#t": ["فكر_عربي", "مفكرين_عرب", "فلسفة_عربية"]
}
];
}
function generateSubId(prefix) {
return prefix + "_" + Math.random().toString(36).slice(2, 8);
}
// ─── Connect to a single relay ───
function connectRelay(url, isReconnect = false) {
let ws;
try {
ws = new WebSocket(url);
} catch (e) {
State.attemptedRelays++;
checkAllRelasFailed();
return;
}
ws._relayUrl = url;
ws._alive = false;
ws.onopen = () => {
ws._alive = true;
State.connectedRelays++;
updateConnectionStatus("connected");
// Subscribe
const subId = generateSubId("arabvid");
const filters = buildSubscriptionFilter();
const req = JSON.stringify(["REQ", subId, ...filters]);
ws.send(req);
if (!isReconnect) {
toast("✅ متّصل بـ " + url.replace("wss://", ""));
}
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg[0] === "EVENT" && msg[2]) {
handleNostrEvent(msg[2]);
}
if (msg[0] === "EOSE") {
// End of stored events
}
} catch (_) {
// Malformed message — ignore
}
};
ws.onerror = () => {
State.attemptedRelays++;
checkAllRelasFailed();
};
ws.onclose = () => {
if (ws._alive) {
State.connectedRelays--;
ws._alive = false;
updateConnectionStatus(
State.connectedRelays > 0 ? "connected" : "error"
);
}
State.attemptedRelays++;
checkAllRelasFailed();
// Auto-reconnect
setTimeout(
() => connectRelay(url, true),
CONFIG.RECONNECT_INTERVAL
);
};
State.sockets.push(ws);
}
// ─── Connect to all relays ───
function connectAllRelays() {
updateConnectionStatus("connecting");
State.connectedRelays = 0;
State.attemptedRelays = 0;
CONFIG.RELAYS.forEach((url) => connectRelay(url));
}
// ─── Manual refresh ───
function refreshFromNostr() {
toast("🔄 جارٍ إعادة الجلب…");
State.sockets.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
const subId = generateSubId("refresh");
const filter = {
kinds: [...CONFIG.VIDEO_KINDS, CONFIG.TEXT_KIND],
limit: CONFIG.MAX_EVENTS_PER_RELAY,
"#t": CONFIG.SEARCH_TAGS
};
ws.send(JSON.stringify(["REQ", subId, filter]));
}
});
}
// ═════════════════════════════════════
// MODULE 7: NOSTR EVENT PARSER
// ═════════════════════════════════════
function handleNostrEvent(event) {
// ── Validation ──
if (!event || !event.id || !event.pubkey) return;
if (State.seenEventIds.has(event.id)) return;
State.seenEventIds.add(event.id);
// ── Extract metadata from tags ──
let videoUrl = "";
let title = "";
let thumb = "";
let thinker = "";
let duration = "—";
const tags = event.tags || [];
for (const tag of tags) {
const [key, value] = tag;
if (!value) continue;
switch (key) {
case "url":
case "r":
videoUrl = value; break;
case "title":
title = value; break;
case "thumb":
case "image":
thumb = value; break;
case "author":
thinker = value; break;
case "duration":
duration = formatDuration(value); break;
}
}
// ── Try to extract YouTube ID ──
const content = event.content || "";
let youtubeId = extractYouTubeId(videoUrl) || extractYouTubeId(content);
// ── Try direct video URL from content ──
if (!videoUrl && !youtubeId) {
videoUrl = extractVideoUrl(content) || "";
}
// ── Must have some video source ──
if (!youtubeId && !videoUrl) return;
// ── Fill defaults ──
if (!title) {
title = content.slice(0, 80) || "فيديو من شبكة Nostr";
}
if (!thumb && youtubeId) {
thumb = CONFIG.YT_THUMB_BASE + youtubeId + "/mqdefault.jpg";
}
// ── Build video object ──
const video = {
id: event.id,
title: title,
thinker: thinker || "مُساهِم Nostr",
category: "nostr",
duration: duration,
description: content,
youtubeId: youtubeId || "",
videoUrl: videoUrl,
thumb: thumb || "",
nostr: true,
pubkey: event.pubkey,
createdAt: event.created_at || 0
};
State.allVideos.push(video);
renderPlaylist();
toast("🟣 فيديو جديد: " + title.slice(0, 40));
}
function formatDuration(seconds) {
const s = parseInt(seconds, 10);
if (isNaN(s)) return seconds;
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}:${sec.toString().padStart(2, "0")}`;
}
// ═════════════════════════════════════
// MODULE 8: VIDEO PLAYER
// ═════════════════════════════════════
function playVideo(index) {
if (index < 0 || index >= State.filtered.length) return;
State.currentIndex = index;
const video = State.filtered[index];
// ── Remove placeholder ──
if (DOM.placeholder && DOM.placeholder.parentNode) {
DOM.placeholder.remove();
DOM.placeholder = null;
}
// ── Build player ──
if (video.youtubeId) {
DOM.videoWrapper.innerHTML = `
<iframe
src="${CONFIG.YT_EMBED_BASE}${sanitize(video.youtubeId)}?autoplay=1&rel=0&hl=ar"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen
title="${sanitize(video.title)}">
</iframe>`;
} else if (video.videoUrl) {
DOM.videoWrapper.innerHTML = `
<video controls autoplay preload="metadata">
<source src="${sanitize(video.videoUrl)}" />
متصفّحك لا يدعم تشغيل الفيديو.
</video>`;
const videoEl = DOM.videoWrapper.querySelector("video");
if (videoEl) {
videoEl.addEventListener("ended", () => {
if (State.autoplay) playNext();
});
}
}
// ── Update "Now Playing" info ──
DOM.nowPlaying.style.display = "block";
DOM.npTitle.textContent = video.title;
DOM.npThinker.textContent = "🎓 " + video.thinker;
DOM.npCategory.textContent = "📂 " + getCategoryLabel(video.category);
DOM.npSource.textContent = video.nostr ? "🟣 Nostr" : "📺 YouTube";
DOM.npDesc.textContent = video.description;
// ── Highlight active item ──
highlightActiveItem(index);
}
function highlightActiveItem(index) {
const items = DOM.playlist.querySelectorAll(".playlist-item");
items.forEach((el, i) => {
el.classList.toggle("active", i === index);
el.setAttribute("aria-current", i === index ? "true" : "false");
});
// Scroll active item into view
const active = DOM.playlist.querySelector(".playlist-item.active");
if (active) {
active.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
function playNext() {
if (State.filtered.length === 0) return;
const next = State.shuffle
? Math.floor(Math.random() * State.filtered.length)
: (State.currentIndex + 1) % State.filtered.length;
playVideo(next);
}
function playPrev() {
if (State.filtered.length === 0) return;
const prev = State.shuffle
? Math.floor(Math.random() * State.filtered.length)
: (State.currentIndex - 1 + State.filtered.length) % State.filtered.length;
playVideo(prev);
}
function toggleShuffle() {
State.shuffle = !State.shuffle;
DOM.btnShuffle.setAttribute("aria-pressed", State.shuffle);
toast(State.shuffle ? "🔀 عشوائي: مفعّل" : "🔀 عشوائي: متوقّف");
}
function toggleAutoplay() {
State.autoplay = !State.autoplay;
DOM.btnAutoplay.setAttribute("aria-pressed", State.autoplay);
toast(State.autoplay ? "▶ تلقائي: مفعّل" : "▶ تلقائي: متوقّف");
}
// ═════════════════════════════════════
// MODULE 9: PLAYLIST RENDERER
// ═════════════════════════════════════
function applyFilters() {
const query = State.searchQuery.toLowerCase();
State.filtered = State.allVideos.filter((v) => {
// Category filter
const matchCategory =
State.currentFilter === "all" ||
v.category === State.currentFilter;
// Search filter
const matchSearch =
!query ||
v.title.toLowerCase().includes(query) ||
v.thinker.toLowerCase().includes(query) ||
(v.description || "").toLowerCase().includes(query);
return matchCategory && matchSearch;
});
}
function renderPlaylist() {
applyFilters();
DOM.videoCount.textContent = State.filtered.length;
// ── Build HTML using DocumentFragment for performance ──
const fragment = document.createDocumentFragment();
State.filtered.forEach((video, index) => {
const item = document.createElement("div");
item.className =
"playlist-item" +
(index === State.currentIndex ? " active" : "");
item.setAttribute("role", "listitem");
item.setAttribute("tabindex", "0");
item.setAttribute(
"aria-current",
index === State.currentIndex ? "true" : "false"
);
item.setAttribute("aria-label", video.title);
// Fallback SVG for missing thumbnails
const fallbackThumb = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="90" height="52">' +
'<rect fill="#222" width="90" height="52"/>' +
'<text x="45" y="30" text-anchor="middle" fill="#666" font-size="12">🎬</text></svg>'
)}`;
item.innerHTML = `
<span class="item-number">${index + 1}</span>
<div class="item-thumb">
<img src="${sanitize(video.thumb || fallbackThumb)}"
alt=""
loading="lazy"
onerror="this.src='${fallbackThumb}'" />
<span class="dur">${sanitize(video.duration)}</span>
</div>
<div class="item-info">
<h4>${sanitize(video.title)}</h4>
<span class="sub">${sanitize(video.thinker)}</span>
${video.nostr ? '<span class="nostr-badge">NOSTR</span>' : ""}
</div>`;
// Click handler
item.addEventListener("click", () => playVideo(index));
// Keyboard: Enter/Space to play
item.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
playVideo(index);
}
});
fragment.appendChild(item);
});
DOM.playlist.innerHTML = "";
DOM.playlist.appendChild(fragment);
}
// ═════════════════════════════════════
// MODULE 10: FILTER & SEARCH
// ═════════════════════════════════════
function setFilter(filter, button) {
State.currentFilter = filter;
State.currentIndex = -1;
// Update tab UI
DOM.filterButtons.forEach((btn) => {
const isActive = btn === button;
btn.classList.toggle("active", isActive);
btn.setAttribute("aria-selected", isActive);
});
renderPlaylist();
}
const handleSearch = debounce(() => {
State.searchQuery = DOM.searchInput.value.trim();
State.currentIndex = -1;
renderPlaylist();
}, CONFIG.DEBOUNCE_DELAY);
// ═════════════════════════════════════
// MODULE 11: KEYBOARD SHORTCUTS
// ═════════════════════════════════════
function handleGlobalKeydown(e) {
// Don't hijack input fields
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") {
return;
}
switch (e.key) {
case "ArrowLeft":
case "n":
e.preventDefault();
playNext();
break;
case "ArrowRight":
case "p":
e.preventDefault();
playPrev();
break;
case "s":
toggleShuffle();
break;
case "a":
toggleAutoplay();
break;
case "f":
case "/":
e.preventDefault();
DOM.searchInput.focus();
break;
}
}
// ═════════════════════════════════════
// MODULE 12: EVENT BINDING
// ═════════════════════════════════════
function bindEvents() {
// Playback controls
DOM.btnPrev.addEventListener("click", playPrev);
DOM.btnNext.addEventListener("click", playNext);
DOM.btnShuffle.addEventListener("click", toggleShuffle);
DOM.btnAutoplay.addEventListener("click", toggleAutoplay);
DOM.btnRefresh.addEventListener("click", refreshFromNostr);
// Search
DOM.searchInput.addEventListener("input", handleSearch);
// Filter tabs
DOM.filterButtons.forEach((btn) => {
btn.addEventListener("click", () => {
setFilter(btn.dataset.filter, btn);
});
});
// Global keyboard shortcuts
document.addEventListener("keydown", handleGlobalKeydown);
// Escape clears search
DOM.searchInput.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
DOM.searchInput.value = "";
DOM.searchInput.blur();
handleSearch();
}
});
}
// ═════════════════════════════════════
// MODULE 13: INITIALIZATION
// ═════════════════════════════════════
function init() {
// 1. Cache DOM references
cacheDOMReferences();
// 2. Load local videos into state
State.allVideos = [...LOCAL_VIDEOS];
// 3. Bind all event listeners
bindEvents();
// 4. Initial render
renderPlaylist();
// 5. Connect to Nostr relays
connectAllRelays();
// 6. Set initial toggle states
DOM.btnAutoplay.setAttribute("aria-pressed", "true");
// 7. Welcome toast
toast("مرحبًا بك في مكتبة المفكّرين العرب 📚");
}
// ── Start when DOM is ready ──
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(); // End IIFE
```
---
## PART 4 — BEST PRACTICES DEEP DIVE
### 4.1 Page Stability Techniques
```
┌──────────────────────────────────────────────────────────────────┐
│ STABILITY STRATEGY MATRIX │
├────────────────────────┬─────────────────────────────────────────┤
│ Problem │ Solution │
├────────────────────────┼─────────────────────────────────────────┤
│ Layout Shift (CLS) │ Fixed aspect-ratio on video-wrapper │
│ │ (padding-top: 56.25%) │
│ │ aspect-ratio on thumbnails │
│ │ contain: layout style on wrapper │
├────────────────────────┼─────────────────────────────────────────┤
│ Memory Leaks │ IIFE encapsulation (no global leaks) │
│ │ Proper event listener cleanup │
│ │ innerHTML replacement (destroys old) │
├────────────────────────┼─────────────────────────────────────────┤
│ WebSocket Crashes │ Try/catch on construction │
│ │ Auto-reconnect with backoff │
│ │ Graceful degradation to local mode │
├────────────────────────┼─────────────────────────────────────────┤
│ Render Blocking │ DocumentFragment for batch DOM updates │
│ │ contain: content on playlist │
│ │ loading="lazy" on images │
├────────────────────────┼─────────────────────────────────────────┤
│ Scroll Jank │ will-change: transform on sticky header │
│ │ backface-visibility: hidden │
│ │ Minimal reflows during interaction │
├────────────────────────┼─────────────────────────────────────────┤
│ Duplicate Events │ seenEventIds Set deduplication │
│ │ Check before DOM insertion │
├────────────────────────┼─────────────────────────────────────────┤
│ XSS from Nostr Data │ sanitize() function on all user data │
│ │ textContent instead of innerHTML │
│ │ for dynamic text │
└────────────────────────┴─────────────────────────────────────────┘
```
### 4.2 Smooth Navigation Patterns
```
┌────────────────────────────────────────────────────────────┐
│ NAVIGATION FLOW DIAGRAM │
│ │
│ ┌─────────────┐ │
│ │ User │ │
│ │ Action │ │
│ └──────┬──────┘ │
│ │ │
│ ┌────┴─────┬──────────┬───────────┬────────────┐ │
│ ▼ ▼ ▼ ▼ ▼ │
│ [Click [Keyboard [Search [Filter [Scroll │
│ Item] ←→ n/p] Input] Tab] List] │
│ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ playVideo playNext/ debounce setFilter smooth │
│ (index) playPrev (300ms) + render scrollInto │
│ │ │ │ │ View │
│ └────┬─────┴──────────┴───────────┘ │
│ ▼ │
│ renderPlaylist() │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ DocumentFragment │ │
│ │ (batch DOM update) │ │
│ └──────────┬───────────┘ │
│ ▼ │
│ DOM Updated │
│ (single reflow) │
└────────────────────────────────────────────────────────────┘
```
**Key Practices:**
```javascript
// ✅ GOOD: Debounced search (prevents jank during typing)
const handleSearch = debounce(() => {
State.searchQuery = DOM.searchInput.value.trim();
renderPlaylist();
}, 300);
// ❌ BAD: Direct event handler (fires on every keystroke)
// DOM.searchInput.addEventListener("input", () => {
// renderPlaylist(); // 60+ renders per second while typing!
// });
// ✅ GOOD: DocumentFragment (single DOM mutation)
const fragment = document.createDocumentFragment();
items.forEach(item => fragment.appendChild(createItem(item)));
container.innerHTML = "";
container.appendChild(fragment);
// ❌ BAD: Multiple individual appends (N mutations)
// items.forEach(item => {
// container.appendChild(createItem(item)); // Reflow each time!
// });
// ✅ GOOD: Smooth scroll to active item
active.scrollIntoView({ behavior: "smooth", block: "center" });
```
### 4.3 Nostr-Specific Best Practices
```
┌──────────────────────────────────────────────────────────────┐
│ NOSTR BEST PRACTICES CHECKLIST │
├──────────────────────────────────────────────────────────────┤
│ │
│ ☑ Connect to 3-5 relays (redundancy without overhead) │
│ ☑ Use specific tag filters (don't request everything) │
│ ☑ Deduplicate events by ID (same event from N relays) │
│ ☑ Handle EOSE ("end of stored events") gracefully │
│ ☑ Auto-reconnect with exponential/fixed backoff │
│ ☑ Validate event structure before processing │
│ ☑ Sanitize ALL data from events (XSS prevention) │
│ ☑ Provide local fallback data (works offline) │
│ ☑ Show connection status to user (transparency) │
│ ☑ Use subscription IDs to manage multiple subs │
│ ☑ Close subscriptions you no longer need │
│ ☑ Handle relay errors without crashing the app │
│ │
└──────────────────────────────────────────────────────────────┘
```
---
## PART 5 — POTENTIAL CHALLENGES & SOLUTIONS
```
┌────────────────────────────────────────────────────────────────────────┐
│ CHALLENGE → SOLUTION MAP │
├───────────────────────┬────────────────────────────────────────────────┤
│ │ │
│ CHALLENGE 1 │ SOLUTION │
│ No video content │ • Maintain curated LOCAL_VIDEOS array │
│ on Nostr relays │ • App works 100% without Nostr │
│ for Arab thinkers │ • Nostr adds content dynamically │
│ │ • Provide "publish" guide for community │
│ │ │
├───────────────────────┼────────────────────────────────────────────────┤
│ │ │
│ CHALLENGE 2 │ SOLUTION │
│ WebSocket blocking │ • Multiple relay connections │
│ by corporate │ • Graceful degradation │
│ firewalls │ • Show "offline mode" status clearly │
│ │ • Consider WSS-over-443 relays │
│ │ │
├───────────────────────┼────────────────────────────────────────────────┤
│ │ │
│ CHALLENGE 3 │ SOLUTION │
│ YouTube iframe │ • Use youtube-nocookie.com domain │
│ inconsistencies │ • Fallback to direct link │
│ & blocking │ • Support direct video URLs (mp4/webm) │
│ │ • Privacy-enhanced mode embed │
│ │ │
├───────────────────────┼────────────────────────────────────────────────┤
│ │ │
│ CHALLENGE 4 │ SOLUTION │
│ RTL layout bugs │ • dir="rtl" on <html> │
│ │ • Use logical properties (inset, etc.) │
│ │ • border-right for active indicator │
│ │ • Test in Chrome, Firefox, Safari │
│ │ │
├───────────────────────┼────────────────────────────────────────────────┤
│ │ │
│ CHALLENGE 5 │ SOLUTION │
│ Spam / malicious │ • sanitize() all Nostr-sourced data │
│ events from Nostr │ • Validate event has video content │
│ │ • Optional: whitelist trusted pubkeys │
│ │ • Never use innerHTML with raw event data │
│ │ │
├───────────────────────┼────────────────────────────────────────────────┤
│ │ │
│ CHALLENGE 6 │ SOLUTION │
│ Mobile performance │ • Responsive grid → single column │
│ │ • loading="lazy" on all images │
│ │ • contain: content on scrollable areas │
│ │ • Touch-friendly tap targets (48px min) │
│ │ │
├───────────────────────┼────────────────────────────────────────────────┤
│ │ │
│ CHALLENGE 7 │ SOLUTION │
│ Autoplay between │ • YouTube: poll for state changes │
│ videos │ • HTML5 video: listen to 'ended' event │
│ │ • OR: Use YouTube IFrame API for precise │
│ │ state callbacks (onStateChange) │
│ │ │
└───────────────────────┴────────────────────────────────────────────────┘
```
---
## PART 6 — HOW TO PUBLISH VIDEO EVENTS TO NOSTR
For community members who want to publish videos that appear in this app:
### 6.1 Event Structure (Kind 34235 — Horizontal Video)
```json
{
"kind": 34235,
"content": "وصف تفصيلي للمحاضرة أو الفيديو",
"tags": [
["d", "unique-slug-for-this-video"],
["title", "عنوان الفيديو — اسم المفكّر"],
["url", "
["thumb", "https://img.youtube.com/vi/XXXXXXXXXXX/maxresdefault.jpg"],
["author", "اسم المفكّر"],
["duration", "2730"],
["t", "فلسفة"],
["t", "مفكر"],
["t", "عربي"],
["t", "فكر_عربي"],
["alt", "Description in English for accessibility"]
]
}
```
### 6.2 Publishing via CLI (Using `nostril` or `nak`)
```bash
# Using nak (recommended)
nak event \
--kind 34235 \
--content "الجابري يشرح نقد العقل العربي" \
--tag d="jabri-naqd-001" \
--tag title="نقد العقل العربي — الجابري" \
--tag url=" \
--tag thumb="https://img.youtube.com/vi/RXG3S-IZWSE/mqdefault.jpg" \
--tag author="محمد عابد الجابري" \
--tag t="فلسفة" \
--tag t="مفكر" \
--tag t="عربي" \
--sec <your-private-key-hex> \
wss://relay.damus.io wss://nos.lol
```
---
## PART 7 — KEYBOARD SHORTCUTS REFERENCE
```
┌──────────────────────────────────────────┐
│ KEYBOARD SHORTCUTS │
├──────────┬───────────────────────────────┤
│ Key │ Action │
├──────────┼───────────────────────────────┤
│ ← │ التالي (next video) │
│ → │ السابق (previous video) │
│ n │ التالي (next video) │
│ p │ السابق (previous video) │
│ s │ تبديل العشوائي (shuffle) │
│ a │ تبديل التلقائي (autoplay) │
│ f or / │ التركيز على البحث (search) │
│ Escape │ إلغاء البحث (clear search) │
└──────────┴───────────────────────────────┘
```
---
## PART 8 — DEPLOYMENT CHECKLIST
```
┌─────────────────────────────────────────────────┐
│ DEPLOYMENT CHECKLIST ✓ │
├─────────────────────────────────────────────────┤
│ │
│ □ Save as single index.html file │
│ □ Open in browser — verify local videos load │
│ □ Check green dot (Nostr connection) │
│ □ Test search: type "الجابري" │
│ □ Test filters: click "فلسفة", "أدب", "تاريخ" │
│ □ Test playback: click any video │
│ □ Test next/prev buttons │
│ □ Test keyboard shortcuts (n, p, s, f) │
│ □ Test mobile: resize to 400px width │
│ □ Test offline: disconnect WiFi │
│ → App should show local videos │
│ □ Deploy to any static host: │
│ → GitHub Pages │
│ → Netlify │
│ → Vercel │
│ → Any web server (no backend needed!) │
│ │
└─────────────────────────────────────────────────┘
```
---
> **الخلاصة**: هذا الدليل يمنحك تطبيقًا متكاملًا لا يحتاج خادمًا خلفيًا — فقط ملف HTML واحد يعمل مباشرة في المتصفّح، يتّصل بشبكة Nostr اللامركزية لجلب المحتوى ديناميكيًا مع مكتبة محلّية كمخزون احتياطي. التصميم كلاسيكي ومستقر يركّز على المحتوى بدلاً من المؤثّرات.
المتعلم المفكر++
Pixion_ARAB@seewaan.com
npub15eq2...grk7
دعنا نبحث سويا عن إدانة الاحتلال والبحث وراء إدعائاته من خلال https://www.perplexity.ai/spaces/mn-jl-flstyn-for-the-sake-of-p-206N7L.fQYu9WqwQHiRBvw#0 أبحث وانشر هذا حتى يتعلم البقية.🇵🇸
رابط صفحة المجموعة- قريبا
https://chachi.chat/communities.nos.social/Q5aCbiU6aQRGXIy9
iso3166:AF
كيف نفتح سيرفر بهذا النطاق؟
- لاحظت وجوده لكنه لم يكن معي ميعاد مسبق.
هذا الرابط
وبعض التعليقات

هذا الرابط

Nests
Nests - Live Audio Rooms on Nostr
Host and join live audio conversations on the decentralized Nostr network. No algorithms, no gatekeepers, just real conversations.

Join me in "New MoQ Who Dis?" on Nests!
View article →
مفيد