mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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 <qwen-coder@alibabacloud.com> * 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 <qwen-coder@alibabacloud.com> Co-authored-by: Elie Habib <elie.habib@gmail.com>
This commit is contained in:
20
.vercelignore
Normal file
20
.vercelignore
Normal file
@@ -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
|
||||
@@ -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}
|
||||
</style>
|
||||
|
||||
<!-- Google Fonts (Nunito for happy variant) -->
|
||||
<!-- Google Fonts (Nunito for happy variant, Tajawal for Arabic) -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,300;0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,300;0,400;0,600;0,700;1,400&family=Tajawal:wght@200;300;400;500;700;800;900&display=swap" rel="stylesheet">
|
||||
<script>window.addEventListener('load',function(){var s=document.createElement('script');s.async=1;s.src='https://emrldco.com/NTA0NDAw.js?t=504400';document.head.appendChild(s);});</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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 = `<!DOCTYPE html><html><head>
|
||||
const og = VARIANT_OG[variant as keyof typeof VARIANT_OG];
|
||||
if (og) {
|
||||
const html = `<!DOCTYPE html><html><head>
|
||||
<meta property="og:type" content="website"/>
|
||||
<meta property="og:title" content="${og.title}"/>
|
||||
<meta property="og:description" content="${og.description}"/>
|
||||
@@ -77,14 +77,15 @@ export default function middleware(request: Request) {
|
||||
<meta name="twitter:image" content="${og.image}"/>
|
||||
<title>${og.title}</title>
|
||||
</head><body></body></html>`;
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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<string, LiveChannel>();
|
||||
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
|
||||
<label class="live-news-manage-add-label" for="liveChannelsHandle">${escapeHtml(t('components.liveNews.youtubeHandleOrUrl') ?? 'YouTube handle or URL')}</label>
|
||||
<input type="text" class="live-news-manage-handle" id="liveChannelsHandle" placeholder="@Channel or youtube.com/watch?v=..." />
|
||||
</div>
|
||||
<div class="live-news-manage-add-field">
|
||||
<label class="live-news-manage-add-label" for="liveChannelsHlsUrl">${escapeHtml(t('components.liveNews.hlsUrl') ?? 'HLS Stream URL (optional)')}</label>
|
||||
<input type="text" class="live-news-manage-handle" id="liveChannelsHlsUrl" placeholder="https://example.com/stream.m3u8" />
|
||||
</div>
|
||||
<div class="live-news-manage-add-field">
|
||||
<label class="live-news-manage-add-label" for="liveChannelsName">${escapeHtml(t('components.liveNews.displayName') ?? 'Display name (optional)')}</label>
|
||||
<input type="text" class="live-news-manage-name" id="liveChannelsName" placeholder="" />
|
||||
@@ -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 = '';
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user