المتعلم المفكر++'s avatar
المتعلم المفكر++
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
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 اللامركزية لجلب المحتوى ديناميكيًا مع مكتبة محلّية كمخزون احتياطي. التصميم كلاسيكي ومستقر يركّز على المحتوى بدلاً من المؤثّرات.