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),