From 02f3fe77a9ebfd0ee60514d1e4405ecec67ff236 Mon Sep 17 00:00:00 2001 From: Hasan AlDoy Date: Thu, 5 Mar 2026 09:16:43 +0300 Subject: [PATCH] feat: Arabic font support and HLS live streaming UI (#1020) * feat: enhance support for HLS streams and update font styles * chore: add .vercelignore to exclude large local build artifacts from Vercel deploys * chore: include node types in tsconfig to fix server type errors on Vercel build * fix(middleware): guard optional variant OG lookup to satisfy strict TS * fix: desktop build and live channels handle null safety - scripts/build-sidecar-sebuf.mjs: Skip building removed [domain]/v1/[rpc].ts (removed in #785) - src/live-channels-window.ts: Add optional chaining for handle property to prevent null errors - src-tauri/Cargo.lock: Bump version to 2.5.24 Co-authored-by: Qwen-Coder * fix: address review issues on PR #1020 - Remove AGENTS.md (project guidelines belong to repo owner) - Restore tracking script in index.html (accidentally removed) - Revert tsconfig.json "node" types (leaks Node globals to frontend) - Add protocol validation to isHlsUrl() (security: block non-http URIs) - Revert Cargo.lock version bump (release management concern) * fix: address P2/P3 review findings - Preserve hlsUrl for HLS-only channels in refreshChannelInfo (was incorrectly clearing the stream URL on every refresh cycle) - Replace deprecated .substr() with .substring() - Extract duplicated HLS display name logic into getChannelDisplayName() --------- Co-authored-by: Qwen-Coder Co-authored-by: Elie Habib --- .vercelignore | 20 +++++++++ index.html | 4 +- middleware.ts | 23 +++++----- scripts/build-sidecar-sebuf.mjs | 43 +++++++++++-------- src/components/LiveNewsPanel.ts | 28 +++++++++--- src/live-channels-window.ts | 76 +++++++++++++++++++++++++++++---- src/locales/en.json | 4 +- src/styles/main.css | 2 +- 8 files changed, 155 insertions(+), 45 deletions(-) create mode 100644 .vercelignore diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 000000000..0a1fbf8cf --- /dev/null +++ b/.vercelignore @@ -0,0 +1,20 @@ +# Exclude local desktop build artifacts and sidecar binaries from deployments +# These files are large and not needed by the Vercel-hosted frontend/API + +# Tauri build outputs +src-tauri/target/ +src-tauri/bundle/ + +# Sidecar and bundled node binaries +src-tauri/sidecar/ +src-tauri/**/node + +# macOS disk images and app bundles +**/*.dmg +**/*.app + +# Rust/Cargo build artifacts (safety) +target/ + +# Common local artifacts +.DS_Store diff --git a/index.html b/index.html index a3ae82695..f178d970f 100644 --- a/index.html +++ b/index.html @@ -157,10 +157,10 @@ [data-variant="happy"][data-theme="dark"] .skeleton-line{background:linear-gradient(90deg,rgba(139,175,122,.05) 25%,rgba(139,175,122,.10) 50%,rgba(139,175,122,.05) 75%);background-size:200% 100%;animation:skel-shimmer 1.5s infinite} - + - + diff --git a/middleware.ts b/middleware.ts index ce3aef946..3161dc9e1 100644 --- a/middleware.ts +++ b/middleware.ts @@ -63,9 +63,9 @@ export default function middleware(request: Request) { if (path === '/' && SOCIAL_PREVIEW_UA.test(ua)) { const variant = VARIANT_HOST_MAP[host]; if (variant && isAllowedHost(host)) { - const og = VARIANT_OG[variant] as { title: string; description: string; image: string; url: string } | undefined; - if (!og) return; - const html = ` + const og = VARIANT_OG[variant as keyof typeof VARIANT_OG]; + if (og) { + const html = ` @@ -77,14 +77,15 @@ export default function middleware(request: Request) { ${og.title} `; - return new Response(html, { - status: 200, - headers: { - 'Content-Type': 'text/html; charset=utf-8', - 'Cache-Control': 'no-store', - 'Vary': 'User-Agent, Host', - }, - }); + return new Response(html, { + status: 200, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'Vary': 'User-Agent, Host', + }, + }); + } } } diff --git a/scripts/build-sidecar-sebuf.mjs b/scripts/build-sidecar-sebuf.mjs index 33b491dc0..d6d25037a 100644 --- a/scripts/build-sidecar-sebuf.mjs +++ b/scripts/build-sidecar-sebuf.mjs @@ -5,12 +5,16 @@ * * Run: node scripts/build-sidecar-sebuf.mjs * Or: npm run build:sidecar-sebuf + * + * Note: api/[domain]/v1/[rpc].ts was removed in #785 as it was a catch-all + * that intercepted all RPC routes. This script now skips the [domain] folder. */ import { build } from 'esbuild'; import { stat } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; +import { existsSync } from 'node:fs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, '..'); @@ -18,22 +22,27 @@ const projectRoot = path.resolve(__dirname, '..'); const entryPoint = path.join(projectRoot, 'api', '[domain]', 'v1', '[rpc].ts'); const outfile = path.join(projectRoot, 'api', '[domain]', 'v1', '[rpc].js'); -try { - await build({ - entryPoints: [entryPoint], - outfile, - bundle: true, - format: 'esm', - platform: 'node', - target: 'node18', - // Tree-shake unused exports for smaller bundle - treeShaking: true, - }); +// Skip if the source file doesn't exist (removed in #785) +if (!existsSync(entryPoint)) { + console.log('build:sidecar-sebuf skipped (api/[domain]/v1/[rpc].ts removed in #785)'); +} else { + try { + await build({ + entryPoints: [entryPoint], + outfile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'node18', + // Tree-shake unused exports for smaller bundle + treeShaking: true, + }); - const { size } = await stat(outfile); - const sizeKB = (size / 1024).toFixed(1); - console.log(`build:sidecar-sebuf api/[domain]/v1/[rpc].js ${sizeKB} KB`); -} catch (err) { - console.error('build:sidecar-sebuf failed:', err.message); - process.exit(1); + const { size } = await stat(outfile); + const sizeKB = (size / 1024).toFixed(1); + console.log(`build:sidecar-sebuf api/[domain]/v1/[rpc].js ${sizeKB} KB`); + } catch (err) { + console.error('build:sidecar-sebuf failed:', err.message); + process.exit(1); + } } diff --git a/src/components/LiveNewsPanel.ts b/src/components/LiveNewsPanel.ts index fb6dd73d9..8f379a80f 100644 --- a/src/components/LiveNewsPanel.ts +++ b/src/components/LiveNewsPanel.ts @@ -50,7 +50,7 @@ declare global { export interface LiveChannel { id: string; name: string; - handle: string; // YouTube channel handle (e.g., @bloomberg) + handle?: string; // YouTube channel handle (e.g., @bloomberg) - optional for HLS streams fallbackVideoId?: string; // Fallback if no live stream detected videoId?: string; // Dynamically fetched live video ID isLive?: boolean; @@ -389,7 +389,7 @@ export class LiveNewsPanel extends Panel { const label = document.createElement('div'); label.style.cssText = 'color:var(--text-secondary);font-size:13px;'; - label.textContent = this.activeChannel.name; + label.textContent = this.getChannelDisplayName(this.activeChannel); const playBtn = document.createElement('button'); playBtn.className = 'offline-retry'; @@ -744,12 +744,18 @@ export class LiveNewsPanel extends Panel { this.syncPlayerState(); } + private getChannelDisplayName(channel: LiveChannel): string { + return channel.hlsUrl && !channel.handle ? `${channel.name} 🔗` : channel.name; + } + /** Creates a single channel tab button with click and drag handlers. */ private createChannelButton(channel: LiveChannel): HTMLButtonElement { const btn = document.createElement('button'); btn.className = `live-channel-btn ${channel.id === this.activeChannel.id ? 'active' : ''}`; btn.dataset.channelId = channel.id; - btn.textContent = channel.name; + + btn.textContent = this.getChannelDisplayName(channel); + btn.style.cursor = 'grab'; btn.addEventListener('click', (e) => { if (this.suppressChannelClick) { @@ -920,6 +926,14 @@ export class LiveNewsPanel extends Panel { channel.hlsUrl = undefined; return; } + + // Skip fetchLiveVideoInfo for channels without handle (HLS-only) + if (!channel.handle) { + channel.videoId = channel.fallbackVideoId; + channel.isLive = false; + return; + } + const info = await fetchLiveVideoInfo(channel.handle); channel.videoId = info.videoId || channel.fallbackVideoId; channel.isLive = !!info.videoId; @@ -990,7 +1004,9 @@ export class LiveNewsPanel extends Panel { this.destroyPlayer(); const watchUrl = channel.videoId ? `https://www.youtube.com/watch?v=${encodeURIComponent(channel.videoId)}` - : `https://www.youtube.com/${encodeURIComponent(channel.handle)}`; + : channel.handle + ? `https://www.youtube.com/${encodeURIComponent(channel.handle)}` + : 'https://www.youtube.com'; const safeName = escapeHtml(channel.name); this.content.innerHTML = ` @@ -1346,7 +1362,9 @@ export class LiveNewsPanel extends Panel { const channel = this.activeChannel; const watchUrl = channel.videoId ? `https://www.youtube.com/watch?v=${encodeURIComponent(channel.videoId)}` - : `https://www.youtube.com/${encodeURIComponent(channel.handle)}`; + : channel.handle + ? `https://www.youtube.com/${encodeURIComponent(channel.handle)}` + : 'https://www.youtube.com'; this.destroyPlayer(); this.content.innerHTML = ''; diff --git a/src/live-channels-window.ts b/src/live-channels-window.ts index 8b8c2c449..27811b79c 100644 --- a/src/live-channels-window.ts +++ b/src/live-channels-window.ts @@ -56,6 +56,17 @@ function parseYouTubeInput(raw: string): { handle: string } | { videoId: string return null; } +/** Check if input is an HLS stream URL (.m3u8) */ +function isHlsUrl(raw: string): boolean { + try { + const url = new URL(raw); + if (url.protocol !== 'https:' && url.protocol !== 'http:') return false; + return url.pathname.endsWith('.m3u8') || raw.includes('.m3u8'); + } catch { + return false; + } +} + // Persist active region tab across re-renders let activeRegionTab = 'all'; @@ -73,11 +84,21 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise const optionalChannelMap = new Map(); for (const c of filteredChannels) optionalChannelMap.set(c.id, c); + let channels: LiveChannel[] = []; + + if (document.getElementById('liveChannelsList')) { + // Already initialized, just update the list + channels = loadChannelsFromStorage(); + const listEl = document.getElementById('liveChannelsList') as HTMLElement; + renderList(listEl); + return; + } + if (!containerEl) { document.title = `${t('components.liveNews.manage') ?? 'Channel management'} - World Monitor`; } - let channels = loadChannelsFromStorage(); + channels = loadChannelsFromStorage(); let suppressRowClick = false; let searchQuery = ''; @@ -179,7 +200,6 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise listEl.appendChild(row); } updateRestoreButton(); - renderAvailableChannels(listEl); } /** Returns default (built-in) channels that are not in the current list. */ @@ -234,7 +254,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise const handleInput = document.createElement('input'); handleInput.type = 'text'; handleInput.className = 'live-news-manage-edit-handle'; - handleInput.value = ch.handle; + handleInput.value = ch.handle ?? ''; handleInput.placeholder = t('components.liveNews.youtubeHandle') ?? 'YouTube handle'; row.appendChild(handleInput); } @@ -262,7 +282,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise saveBtn.className = 'live-news-manage-save'; saveBtn.textContent = t('components.liveNews.save') ?? 'Save'; saveBtn.addEventListener('click', () => { - const displayName = nameInput.value.trim() || ch.name || ch.handle; + const displayName = nameInput.value.trim() || ch.name || ch.handle || ''; const next = applyEditFormToChannels(ch, row, isCustom, displayName); if (next) { channels = next; @@ -300,7 +320,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise .filter((ch): ch is LiveChannel => !!ch); const matchingChannels = term - ? regionChannels.filter(ch => ch.name.toLowerCase().includes(term) || ch.handle.toLowerCase().includes(term)) + ? regionChannels.filter(ch => ch.name.toLowerCase().includes(term) || ch.handle?.toLowerCase().includes(term)) : regionChannels; const addedCount = matchingChannels.filter(ch => currentIds.has(ch.id)).length; @@ -332,7 +352,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise for (const chId of region.channelIds) { const ch = optionalChannelMap.get(chId); if (!ch) continue; - if (term && !ch.name.toLowerCase().includes(term) && !ch.handle.toLowerCase().includes(term)) continue; + if (term && !ch.name.toLowerCase().includes(term) && !ch.handle?.toLowerCase().includes(term)) continue; const isAdded = currentIds.has(chId); grid.appendChild(createCard(ch, isAdded, listEl)); matchCount++; @@ -365,7 +385,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise nameEl.textContent = ch.name; const handleEl = document.createElement('span'); handleEl.className = 'live-news-manage-card-handle'; - handleEl.textContent = ch.handle; + handleEl.textContent = ch.handle ?? ''; info.appendChild(nameEl); info.appendChild(handleEl); @@ -429,6 +449,10 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise +
+ + +
@@ -444,11 +468,15 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise if (!listEl) return; setupListDnD(listEl); renderList(listEl); + renderAvailableChannels(listEl); // Clear validation state on input document.getElementById('liveChannelsHandle')?.addEventListener('input', (e) => { (e.target as HTMLInputElement).classList.remove('invalid'); }); + document.getElementById('liveChannelsHlsUrl')?.addEventListener('input', (e) => { + (e.target as HTMLInputElement).classList.remove('invalid'); + }); document.getElementById('liveChannelsRestoreBtn')?.addEventListener('click', () => { const missing = getMissingDefaultChannels(); @@ -461,10 +489,40 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise const addBtn = document.getElementById('liveChannelsAddBtn') as HTMLButtonElement | null; addBtn?.addEventListener('click', async () => { const handleInput = document.getElementById('liveChannelsHandle') as HTMLInputElement | null; + const hlsInput = document.getElementById('liveChannelsHlsUrl') as HTMLInputElement | null; const nameInput = document.getElementById('liveChannelsName') as HTMLInputElement | null; const raw = handleInput?.value?.trim(); - if (!raw) return; + const hlsUrl = hlsInput?.value?.trim(); + if (!raw && !hlsUrl) return; if (handleInput) handleInput.classList.remove('invalid'); + if (hlsInput) hlsInput.classList.remove('invalid'); + + // Check if HLS URL is provided + if (hlsUrl) { + if (!isHlsUrl(hlsUrl)) { + if (hlsInput) { + hlsInput.classList.add('invalid'); + hlsInput.setAttribute('title', t('components.liveNews.invalidHlsUrl') ?? 'Enter a valid HLS stream URL (.m3u8)'); + } + return; + } + + // Create custom HLS channel + const id = `custom-hls-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + if (channels.some((c) => c.id === id)) return; + + const name = nameInput?.value?.trim() || 'HLS Stream'; + channels.push({ id, name, hlsUrl, useFallbackOnly: true }); + saveChannelsToStorage(channels); + renderList(listEl); + if (handleInput) handleInput.value = ''; + if (hlsInput) hlsInput.value = ''; + if (nameInput) nameInput.value = ''; + return; + } + + // Handle YouTube input (existing logic) + if (!raw) return; // Try parsing as a YouTube URL first const parsed = parseYouTubeInput(raw); @@ -503,6 +561,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise saveChannelsToStorage(channels); renderList(listEl); if (handleInput) handleInput.value = ''; + if (hlsInput) hlsInput.value = ''; if (nameInput) nameInput.value = ''; return; } @@ -561,6 +620,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise saveChannelsToStorage(channels); renderList(listEl); if (handleInput) handleInput.value = ''; + if (hlsInput) hlsInput.value = ''; if (nameInput) nameInput.value = ''; }); diff --git a/src/locales/en.json b/src/locales/en.json index 19ff07093..d94121c8b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1667,7 +1667,9 @@ "regionOceania": "Oceania", "invalidHandle": "Enter a valid YouTube handle (e.g. @ChannelName)", "channelNotFound": "YouTube channel not found", - "verifying": "Verifying…" + "verifying": "Verifying…", + "hlsUrl": "HLS Stream URL (optional)", + "invalidHlsUrl": "Enter a valid HLS stream URL (.m3u8)" }, "map": { "showMap": "Show Map", diff --git a/src/styles/main.css b/src/styles/main.css index e6f947332..1a583b566 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -59,7 +59,7 @@ } [dir="rtl"] { - --font-body: 'Geeza Pro', 'SF Arabic', 'Tahoma', system-ui, sans-serif; + --font-body: 'Tajawal', 'Geeza Pro', 'SF Arabic', 'Tahoma', system-ui, sans-serif; } :lang(zh-CN),