feat(live): custom channel management with review fixes (#282)

* feat(live): custom channel management — add/remove/reorder, standalone window, i18n

- Standalone channel management window (?live-channels=1) with list, add form, restore defaults
- LIVE panel: gear icon opens channel management; channel tabs reorderable via DnD
- Row click to edit; custom modal for delete confirmation (no window.confirm)
- i18n for all locales (manage, addChannel, youtubeHandle, displayName, etc.)
- UI: margin between channel list and add form in management window
- settings-window: panel display settings comment in English

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(tauri): channel management in desktop app, dev base_url fix

- Add live-channels.html and live-channels-main.ts for standalone window
- Tauri: open_live_channels_window_command, close_live_channels_window, open live-channels window (WebviewUrl::App or External from base_url)
- LiveNewsPanel: in desktop runtime invoke Tauri command with base_url (window.location.origin) so dev works when Vite runs on a different port than devUrl
- Vite: add liveChannels entry to build input
- capabilities: add live-channels window
- tauri.conf: devUrl 3000 to match vite server.port
- docs: PR_LIVE_CHANNEL_MANAGEMENT.md for PR #276

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: address review issues in live channel management PR

- Revert settings button to open modal (not window.open popup)
- Revert devUrl from localhost:3000 to localhost:5173
- Guard activeChannel against empty channels (fall back to defaults)
- Escape i18n strings in innerHTML with escapeHtml() to prevent XSS
- Only store displayNameOverrides for actually renamed channels
- Use URL constructor for live-channels window URL
- Add CSP meta tag to live-channels.html
- Remove unused i18n keys (edit, editMode, done) from all locales
- Remove unused CSS classes (live-news-manage-btn/panel/wrap)
- Delete PR instruction doc (PR_LIVE_CHANNEL_MANAGEMENT.md)

---------

Co-authored-by: Masaki <yukkurihakutaku@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Elie Habib
2026-02-23 22:51:44 +00:00
committed by GitHub
parent a37dced84e
commit 6271fafd40
28 changed files with 1321 additions and 69 deletions

14
live-channels.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data: https:;" />
<title>Channel management - World Monitor</title>
<script>(function(){try{var t=localStorage.getItem('worldmonitor-theme');if(t==='light')document.documentElement.dataset.theme='light';}catch(e){}document.documentElement.classList.add('no-transition');})()</script>
</head>
<body style="margin:0;background:var(--bg,#1a1c1e);color:var(--text,#e8eaed)">
<div id="app"></div>
<script type="module" src="/src/live-channels-main.ts"></script>
</body>
</html>

View File

@@ -2,6 +2,6 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capabilities for World Monitor main and settings windows",
"windows": ["main", "settings"],
"windows": ["main", "settings", "live-channels"],
"permissions": ["core:default"]
}

View File

@@ -478,6 +478,24 @@ fn close_settings_window(app: AppHandle) -> Result<(), String> {
Ok(())
}
#[tauri::command]
async fn open_live_channels_window_command(
app: AppHandle,
base_url: Option<String>,
) -> Result<(), String> {
open_live_channels_window(&app, base_url)
}
#[tauri::command]
fn close_live_channels_window(app: AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("live-channels") {
window
.close()
.map_err(|e| format!("Failed to close live channels window: {e}"))?;
}
Ok(())
}
/// Fetch JSON from Polymarket Gamma API using native TLS (bypasses Cloudflare JA3 blocking).
/// Called from frontend when browser CORS and sidecar Node.js TLS both fail.
#[tauri::command]
@@ -533,6 +551,41 @@ fn open_settings_window(app: &AppHandle) -> Result<(), String> {
Ok(())
}
fn open_live_channels_window(app: &AppHandle, base_url: Option<String>) -> Result<(), String> {
if let Some(window) = app.get_webview_window("live-channels") {
let _ = window.show();
window
.set_focus()
.map_err(|e| format!("Failed to focus live channels window: {e}"))?;
return Ok(());
}
// In dev, use the same origin as the main window (e.g. http://localhost:3001) so we don't
// get "connection refused" when Vite runs on a different port than devUrl.
let url = match base_url {
Some(ref origin) if !origin.is_empty() => {
let path = origin.trim_end_matches('/');
let full_url = format!("{}/live-channels.html", path);
WebviewUrl::External(Url::parse(&full_url).map_err(|_| "Invalid base URL".to_string())?)
}
_ => WebviewUrl::App("live-channels.html".into()),
};
let _live_channels_window = WebviewWindowBuilder::new(app, "live-channels", url)
.title("Channel management - World Monitor")
.inner_size(440.0, 560.0)
.min_inner_size(360.0, 480.0)
.resizable(true)
.background_color(tauri::webview::Color(26, 28, 30, 255))
.build()
.map_err(|e| format!("Failed to create live channels window: {e}"))?;
#[cfg(not(target_os = "macos"))]
let _ = _live_channels_window.remove_menu();
Ok(())
}
fn build_app_menu(handle: &AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
let settings_item = MenuItem::with_id(
handle,
@@ -986,6 +1039,8 @@ fn main() {
open_sidecar_log_file,
open_settings_window_command,
close_settings_window,
open_live_channels_window_command,
close_live_channels_window,
open_url,
fetch_polymarket
])

View File

@@ -2645,6 +2645,26 @@ export class App {
document.getElementById('settingsModal')?.classList.add('active');
});
// Sync panel state when settings are changed in the separate settings window
window.addEventListener('storage', (e) => {
if (e.key === STORAGE_KEYS.panels && e.newValue) {
try {
this.panelSettings = JSON.parse(e.newValue) as Record<string, PanelConfig>;
this.applyPanelSettings();
this.renderPanelToggles();
} catch (_) {}
}
if (e.key === 'worldmonitor-intel-findings' && this.findingsBadge) {
this.findingsBadge.setEnabled(e.newValue !== 'hidden');
}
if (e.key === STORAGE_KEYS.liveChannels && e.newValue) {
const panel = this.panels['live-news'];
if (panel && typeof (panel as unknown as { refreshChannelsFromStorage?: () => void }).refreshChannelsFromStorage === 'function') {
(panel as unknown as { refreshChannelsFromStorage: () => void }).refreshChannelsFromStorage();
}
}
});
document.getElementById('modalClose')?.addEventListener('click', () => {
document.getElementById('settingsModal')?.classList.remove('active');
});

View File

@@ -1,7 +1,10 @@
import { Panel } from './Panel';
import { fetchLiveVideoId } from '@/services/live-news';
import { isDesktopRuntime, getRemoteApiBaseUrl } from '@/services/runtime';
import { invokeTauri } from '@/services/tauri-bridge';
import { t } from '../services/i18n';
import { loadFromStorage, saveToStorage } from '@/utils';
import { STORAGE_KEYS } from '@/config';
// YouTube IFrame Player API types
type YouTubePlayer = {
@@ -39,7 +42,7 @@ declare global {
}
}
interface LiveChannel {
export interface LiveChannel {
id: string;
name: string;
handle: string; // YouTube channel handle (e.g., @bloomberg)
@@ -71,11 +74,69 @@ const TECH_LIVE_CHANNELS: LiveChannel[] = [
{ id: 'nasa', name: 'NASA TV', handle: '@NASA', fallbackVideoId: 'fO9e9jnhYK8', useFallbackOnly: true },
];
const LIVE_CHANNELS = SITE_VARIANT === 'tech' ? TECH_LIVE_CHANNELS : FULL_LIVE_CHANNELS;
const DEFAULT_LIVE_CHANNELS = SITE_VARIANT === 'tech' ? TECH_LIVE_CHANNELS : FULL_LIVE_CHANNELS;
/** Default channel list for the current variant (for restore in channel management). */
export function getDefaultLiveChannels(): LiveChannel[] {
return [...DEFAULT_LIVE_CHANNELS];
}
export interface StoredLiveChannels {
order: string[];
custom?: LiveChannel[];
/** Display name overrides for built-in channels (and custom). */
displayNameOverrides?: Record<string, string>;
}
const DEFAULT_STORED: StoredLiveChannels = {
order: DEFAULT_LIVE_CHANNELS.map((c) => c.id),
};
export const BUILTIN_IDS = new Set([
...FULL_LIVE_CHANNELS.map((c) => c.id),
...TECH_LIVE_CHANNELS.map((c) => c.id),
]);
export function loadChannelsFromStorage(): LiveChannel[] {
const stored = loadFromStorage<StoredLiveChannels>(STORAGE_KEYS.liveChannels, DEFAULT_STORED);
const order = stored.order?.length ? stored.order : DEFAULT_STORED.order;
const channelMap = new Map<string, LiveChannel>();
for (const c of FULL_LIVE_CHANNELS) channelMap.set(c.id, { ...c });
for (const c of TECH_LIVE_CHANNELS) channelMap.set(c.id, { ...c });
for (const c of stored.custom ?? []) {
if (c.id && c.handle) channelMap.set(c.id, { ...c });
}
const overrides = stored.displayNameOverrides ?? {};
for (const [id, name] of Object.entries(overrides)) {
const ch = channelMap.get(id);
if (ch) ch.name = name;
}
const result: LiveChannel[] = [];
for (const id of order) {
const ch = channelMap.get(id);
if (ch) result.push(ch);
}
return result;
}
export function saveChannelsToStorage(channels: LiveChannel[]): void {
const order = channels.map((c) => c.id);
const custom = channels.filter((c) => !BUILTIN_IDS.has(c.id));
const builtinNames = new Map<string, string>();
for (const c of [...FULL_LIVE_CHANNELS, ...TECH_LIVE_CHANNELS]) builtinNames.set(c.id, c.name);
const displayNameOverrides: Record<string, string> = {};
for (const c of channels) {
if (builtinNames.has(c.id) && c.name !== builtinNames.get(c.id)) {
displayNameOverrides[c.id] = c.name;
}
}
saveToStorage(STORAGE_KEYS.liveChannels, { order, custom, displayNameOverrides });
}
export class LiveNewsPanel extends Panel {
private static apiPromise: Promise<void> | null = null;
private activeChannel: LiveChannel = LIVE_CHANNELS[0]!;
private channels: LiveChannel[] = [];
private activeChannel!: LiveChannel;
private channelSwitcher: HTMLElement | null = null;
private isMuted = true;
private isPlaying = true;
@@ -109,6 +170,9 @@ export class LiveNewsPanel extends Panel {
this.youtubeOrigin = LiveNewsPanel.resolveYouTubeOrigin();
this.playerElementId = `live-news-player-${Date.now()}`;
this.element.classList.add('panel-wide');
this.channels = loadChannelsFromStorage();
if (this.channels.length === 0) this.channels = getDefaultLiveChannels();
this.activeChannel = this.channels[0]!;
this.createLiveButton();
this.createMuteButton();
this.createChannelSwitcher();
@@ -117,6 +181,10 @@ export class LiveNewsPanel extends Panel {
this.setupIdleDetection();
}
private saveChannels(): void {
saveChannelsToStorage(this.channels);
}
private get embedOrigin(): string {
try { return new URL(getRemoteApiBaseUrl()).origin; } catch { return 'https://worldmonitor.app'; }
}
@@ -302,20 +370,100 @@ export class LiveNewsPanel extends Panel {
this.syncPlayerState();
}
/** 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.draggable = true;
btn.textContent = channel.name;
btn.addEventListener('click', (e) => {
e.preventDefault();
this.switchChannel(channel);
});
btn.addEventListener('dragstart', (e) => {
btn.classList.add('live-channel-dragging');
if (e.dataTransfer) {
e.dataTransfer.setData('text/plain', channel.id);
e.dataTransfer.effectAllowed = 'move';
}
});
btn.addEventListener('dragend', () => {
btn.classList.remove('live-channel-dragging');
this.applyChannelOrderFromDom();
});
return btn;
}
private createChannelSwitcher(): void {
this.channelSwitcher = document.createElement('div');
this.channelSwitcher.className = 'live-news-switcher';
LIVE_CHANNELS.forEach(channel => {
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.addEventListener('click', () => this.switchChannel(channel));
this.channelSwitcher!.appendChild(btn);
for (const channel of this.channels) {
this.channelSwitcher.appendChild(this.createChannelButton(channel));
}
this.channelSwitcher.addEventListener('dragover', (e) => {
e.preventDefault();
const dragging = this.channelSwitcher?.querySelector('.live-channel-dragging');
if (!dragging || !this.channelSwitcher) return;
const target = (e.target as HTMLElement).closest?.('.live-channel-btn');
if (!target || target === dragging) return;
const all = Array.from(this.channelSwitcher.querySelectorAll('.live-channel-btn'));
const idx = all.indexOf(dragging as Element);
const targetIdx = all.indexOf(target);
if (idx === -1 || targetIdx === -1) return;
if (idx < targetIdx) {
target.parentElement?.insertBefore(dragging, target.nextSibling);
} else {
target.parentElement?.insertBefore(dragging, target);
}
});
this.element.insertBefore(this.channelSwitcher, this.content);
const toolbar = document.createElement('div');
toolbar.className = 'live-news-toolbar';
toolbar.appendChild(this.channelSwitcher);
this.createManageButton(toolbar);
this.element.insertBefore(toolbar, this.content);
}
private createManageButton(toolbar: HTMLElement): void {
const openBtn = document.createElement('button');
openBtn.type = 'button';
openBtn.className = 'live-news-settings-btn';
openBtn.title = t('components.liveNews.channelSettings') ?? 'Channel Settings';
openBtn.innerHTML =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
openBtn.addEventListener('click', () => {
if (isDesktopRuntime()) {
void invokeTauri<void>('open_live_channels_window_command', {
base_url: window.location.origin,
}).catch(() => {});
return;
}
const url = new URL(window.location.href);
url.searchParams.set('live-channels', '1');
window.open(url.toString(), 'worldmonitor-live-channels', 'width=440,height=560,scrollbars=yes');
});
toolbar.appendChild(openBtn);
}
private refreshChannelSwitcher(): void {
if (!this.channelSwitcher) return;
this.channelSwitcher.innerHTML = '';
for (const channel of this.channels) {
this.channelSwitcher.appendChild(this.createChannelButton(channel));
}
}
private applyChannelOrderFromDom(): void {
if (!this.channelSwitcher) return;
const ids = Array.from(this.channelSwitcher.querySelectorAll<HTMLElement>('.live-channel-btn'))
.map((el) => el.dataset.channelId)
.filter((id): id is string => !!id);
const orderMap = new Map(this.channels.map((c) => [c.id, c]));
this.channels = ids.map((id) => orderMap.get(id)).filter((c): c is LiveChannel => !!c);
this.saveChannels();
}
private async resolveChannelVideo(channel: LiveChannel, forceFallback = false): Promise<void> {
@@ -682,6 +830,17 @@ export class LiveNewsPanel extends Panel {
this.syncPlayerState();
}
/** Reload channel list from storage (e.g. after edit in separate channel management window). */
public refreshChannelsFromStorage(): void {
this.channels = loadChannelsFromStorage();
if (this.channels.length === 0) this.channels = getDefaultLiveChannels();
if (!this.channels.some((c) => c.id === this.activeChannel.id)) {
this.activeChannel = this.channels[0]!;
void this.switchChannel(this.activeChannel);
}
this.refreshChannelSwitcher();
}
public destroy(): void {
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);

View File

@@ -35,6 +35,7 @@ export const STORAGE_KEYS = {
monitors: 'worldmonitor-monitors',
mapLayers: 'worldmonitor-layers',
disabledFeeds: 'worldmonitor-disabled-feeds',
liveChannels: 'worldmonitor-live-channels',
} as const;
// Type definitions for variant configs

14
src/live-channels-main.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* Entry point for the standalone channel management window (Tauri desktop).
* Web version uses index.html?live-channels=1 and main.ts instead.
*/
import './styles/main.css';
import { initI18n } from '@/services/i18n';
import { initLiveChannelsWindow } from '@/live-channels-window';
async function main(): Promise<void> {
await initI18n();
initLiveChannelsWindow();
}
void main().catch(console.error);

334
src/live-channels-window.ts Normal file
View File

@@ -0,0 +1,334 @@
/**
* Standalone channel management window (LIVE panel: add/remove/reorder channels).
* Loaded when the app is opened with ?live-channels=1 (e.g. from "Manage channels" button).
*/
import type { LiveChannel } from '@/components/LiveNewsPanel';
import {
loadChannelsFromStorage,
saveChannelsToStorage,
BUILTIN_IDS,
getDefaultLiveChannels,
} from '@/components/LiveNewsPanel';
import { t } from '@/services/i18n';
import { escapeHtml } from '@/utils/sanitize';
/** Builds a stable custom channel id from a YouTube handle (e.g. @Foo -> custom-foo). */
function customChannelIdFromHandle(handle: string): string {
const normalized = handle
.replace(/^@/, '')
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return 'custom-' + normalized;
}
function showConfirmModal(options: {
title: string;
message: string;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void;
onCancel: () => void;
}): void {
const { title, message, confirmLabel, cancelLabel, onConfirm, onCancel } = options;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML = `
<div class="modal">
<div class="modal-header">
<span class="modal-title"></span>
<button type="button" class="modal-close">×</button>
</div>
<p class="confirm-modal-message"></p>
<div class="confirm-modal-actions">
<button type="button" class="live-news-manage-cancel confirm-modal-cancel"></button>
<button type="button" class="live-news-manage-remove confirm-modal-confirm"></button>
</div>
</div>
`;
const titleEl = overlay.querySelector('.modal-title');
const messageEl = overlay.querySelector('.confirm-modal-message');
const cancelBtn = overlay.querySelector('.confirm-modal-cancel') as HTMLButtonElement | null;
const confirmBtn = overlay.querySelector('.confirm-modal-confirm') as HTMLButtonElement | null;
const closeBtn = overlay.querySelector('.modal-close') as HTMLButtonElement | null;
if (titleEl) titleEl.textContent = title;
if (messageEl) messageEl.textContent = message;
if (cancelBtn) cancelBtn.textContent = cancelLabel;
if (confirmBtn) confirmBtn.textContent = confirmLabel;
if (closeBtn) closeBtn.setAttribute('aria-label', t('common.close') ?? 'Close');
const close = () => {
overlay.remove();
};
const doConfirm = () => {
close();
onConfirm();
};
overlay.addEventListener('click', (e) => {
if ((e.target as HTMLElement).classList.contains('modal-overlay')) {
close();
onCancel();
}
});
closeBtn?.addEventListener('click', () => {
close();
onCancel();
});
cancelBtn?.addEventListener('click', () => {
close();
onCancel();
});
confirmBtn?.addEventListener('click', () => {
doConfirm();
});
document.body.appendChild(overlay);
overlay.classList.add('active');
}
export function initLiveChannelsWindow(): void {
const appEl = document.getElementById('app');
if (!appEl) return;
document.title = `${t('components.liveNews.manage') ?? 'Channel management'} - World Monitor`;
let channels = loadChannelsFromStorage();
/** Reads current row order from DOM and persists to storage. */
function applyOrderFromDom(listEl: HTMLElement): void {
const rows = listEl.querySelectorAll<HTMLElement>('.live-news-manage-row');
const ids = Array.from(rows).map((el) => el.dataset.channelId).filter((id): id is string => !!id);
const map = new Map(channels.map((c) => [c.id, c]));
channels = ids.map((id) => map.get(id)).filter((c): c is LiveChannel => !!c);
saveChannelsToStorage(channels);
}
function setupListDnD(listEl: HTMLElement): void {
listEl.addEventListener('dragover', (e) => {
e.preventDefault();
const dragging = listEl.querySelector('.live-news-manage-row-dragging');
if (!dragging) return;
const target = (e.target as HTMLElement).closest?.('.live-news-manage-row');
if (!target || target === dragging) return;
const all = Array.from(listEl.querySelectorAll('.live-news-manage-row'));
const idx = all.indexOf(dragging as HTMLElement);
const targetIdx = all.indexOf(target);
if (idx === -1 || targetIdx === -1) return;
if (idx < targetIdx) {
target.parentElement?.insertBefore(dragging, target.nextSibling);
} else {
target.parentElement?.insertBefore(dragging, target);
}
});
}
function renderList(listEl: HTMLElement): void {
listEl.innerHTML = '';
for (const ch of channels) {
const row = document.createElement('div');
row.className = 'live-news-manage-row';
row.dataset.channelId = ch.id;
row.draggable = true;
const didDrag = { value: false };
const nameSpan = document.createElement('span');
nameSpan.className = 'live-news-manage-row-name';
nameSpan.textContent = ch.name ?? '';
row.appendChild(nameSpan);
row.addEventListener('click', (e) => {
if (didDrag.value) return;
// Do not open edit when clicking inside form controls (input, button, etc.)
if ((e.target as HTMLElement).closest('input, button, textarea, select')) return;
e.preventDefault();
showEditForm(row, ch, listEl);
});
row.addEventListener('dragstart', (e) => {
didDrag.value = true;
row.classList.add('live-news-manage-row-dragging');
if (e.dataTransfer) {
e.dataTransfer.setData('text/plain', ch.id);
e.dataTransfer.effectAllowed = 'move';
}
});
row.addEventListener('dragend', () => {
row.classList.remove('live-news-manage-row-dragging');
applyOrderFromDom(listEl);
setTimeout(() => {
didDrag.value = false;
}, 0);
});
listEl.appendChild(row);
}
updateRestoreButton();
}
/** Returns default (built-in) channels that are not in the current list. */
function getMissingDefaultChannels(): LiveChannel[] {
const currentIds = new Set(channels.map((c) => c.id));
return getDefaultLiveChannels().filter((c) => !currentIds.has(c.id));
}
function updateRestoreButton(): void {
const btn = document.getElementById('liveChannelsRestoreBtn');
if (!btn) return;
const missing = getMissingDefaultChannels();
(btn as HTMLButtonElement).style.display = missing.length > 0 ? '' : 'none';
}
/**
* Applies edit form state to channels and returns the new array, or null if nothing to save.
* Used by the Save button in the edit form.
*/
function applyEditFormToChannels(
currentCh: LiveChannel,
formRow: HTMLElement,
isCustom: boolean,
displayName: string,
): LiveChannel[] | null {
const idx = channels.findIndex((c) => c.id === currentCh.id);
if (idx === -1) return null;
if (isCustom) {
const handleRaw = (formRow.querySelector('.live-news-manage-edit-handle') as HTMLInputElement | null)?.value?.trim();
if (handleRaw) {
const handle = handleRaw.startsWith('@') ? handleRaw : `@${handleRaw}`;
const newId = customChannelIdFromHandle(handle);
const existing = channels.find((c) => c.id === newId && c.id !== currentCh.id);
if (existing) return null;
const next = channels.slice();
next[idx] = { ...currentCh, id: newId, handle, name: displayName };
return next;
}
}
const next = channels.slice();
next[idx] = { ...currentCh, name: displayName };
return next;
}
function showEditForm(row: HTMLElement, ch: LiveChannel, listEl: HTMLElement): void {
const isCustom = !BUILTIN_IDS.has(ch.id);
row.draggable = false;
row.innerHTML = '';
row.className = 'live-news-manage-row live-news-manage-row-editing';
if (isCustom) {
const handleInput = document.createElement('input');
handleInput.type = 'text';
handleInput.className = 'live-news-manage-edit-handle';
handleInput.value = ch.handle;
handleInput.placeholder = t('components.liveNews.youtubeHandle') ?? 'YouTube handle';
row.appendChild(handleInput);
}
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'live-news-manage-edit-name';
nameInput.value = ch.name ?? '';
nameInput.placeholder = t('components.liveNews.displayName') ?? 'Display name';
row.appendChild(nameInput);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'live-news-manage-remove live-news-manage-remove-in-form';
removeBtn.textContent = t('components.liveNews.remove') ?? 'Remove';
removeBtn.addEventListener('click', () => {
showConfirmModal({
title: t('components.liveNews.confirmTitle') ?? 'Confirm',
message: t('components.liveNews.confirmDelete') ?? 'Delete this channel?',
cancelLabel: t('components.liveNews.cancel') ?? 'Cancel',
confirmLabel: t('components.liveNews.remove') ?? 'Remove',
onCancel: () => {},
onConfirm: () => {
channels = channels.filter((c) => c.id !== ch.id);
saveChannelsToStorage(channels);
renderList(listEl);
},
});
});
row.appendChild(removeBtn);
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
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 next = applyEditFormToChannels(ch, row, isCustom, displayName);
if (next) {
channels = next;
saveChannelsToStorage(channels);
}
renderList(listEl);
});
row.appendChild(saveBtn);
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'live-news-manage-cancel';
cancelBtn.textContent = t('components.liveNews.cancel') ?? 'Cancel';
cancelBtn.addEventListener('click', () => {
renderList(listEl);
});
row.appendChild(cancelBtn);
}
appEl.innerHTML = `
<div class="live-channels-window-shell">
<div class="live-channels-window-header">
<span class="live-channels-window-title">${escapeHtml(t('components.liveNews.manage') ?? 'Channel management')}</span>
</div>
<div class="live-channels-window-content">
<div class="live-channels-window-toolbar">
<button type="button" class="live-news-manage-restore-defaults" id="liveChannelsRestoreBtn" style="display: none;">${escapeHtml(t('components.liveNews.restoreDefaults') ?? 'Restore default channels')}</button>
</div>
<div class="live-news-manage-list" id="liveChannelsList"></div>
<div class="live-news-manage-add-section">
<span class="live-news-manage-add-title">${escapeHtml(t('components.liveNews.addChannel') ?? 'Add channel')}</span>
<div class="live-news-manage-add">
<div class="live-news-manage-add-field">
<label class="live-news-manage-add-label" for="liveChannelsHandle">${escapeHtml(t('components.liveNews.youtubeHandle') ?? 'YouTube handle (e.g. @Channel)')}</label>
<input type="text" class="live-news-manage-handle" id="liveChannelsHandle" placeholder="@Channel" />
</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="" />
</div>
<button type="button" class="live-news-manage-add-btn" id="liveChannelsAddBtn">${escapeHtml(t('components.liveNews.addChannel') ?? 'Add channel')}</button>
</div>
</div>
</div>
</div>
`;
const listEl = document.getElementById('liveChannelsList');
if (!listEl) return;
setupListDnD(listEl);
renderList(listEl);
document.getElementById('liveChannelsRestoreBtn')?.addEventListener('click', () => {
const missing = getMissingDefaultChannels();
if (missing.length === 0) return;
channels = [...channels, ...missing];
saveChannelsToStorage(channels);
renderList(listEl);
});
document.getElementById('liveChannelsAddBtn')?.addEventListener('click', () => {
const handleInput = document.getElementById('liveChannelsHandle') as HTMLInputElement | null;
const nameInput = document.getElementById('liveChannelsName') as HTMLInputElement | null;
const raw = handleInput?.value?.trim();
if (!raw) return;
const handle = raw.startsWith('@') ? raw : `@${raw}`;
const name = nameInput?.value?.trim() || handle;
const id = customChannelIdFromHandle(handle);
if (channels.some((c) => c.id === id)) return;
channels.push({ id, name, handle });
saveChannelsToStorage(channels);
renderList(listEl);
if (handleInput) handleInput.value = '';
if (nameInput) nameInput.value = '';
});
}

View File

@@ -1160,7 +1160,19 @@
"retry": "Retry",
"notLive": "{{name}} is not currently live",
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
"openOnYouTube": "Open on YouTube"
"openOnYouTube": "Open on YouTube",
"manage": "إدارة القنوات",
"addChannel": "إضافة قناة",
"remove": "إزالة",
"youtubeHandle": "معرّف YouTube (مثال: @Channel)",
"displayName": "اسم العرض (اختياري)",
"openPanelSettings": "إعدادات عرض اللوحة",
"channelSettings": "إعدادات القناة",
"save": "حفظ",
"cancel": "إلغاء",
"confirmDelete": "حذف هذه القناة؟",
"confirmTitle": "تأكيد",
"restoreDefaults": "استعادة القنوات الافتراضية"
}
},
"popups": {
@@ -1750,7 +1762,7 @@
"noDataAvailable": "لا تتوفر بيانات",
"updated": "تم التحديث للتو",
"ago": "منذ {{time}}",
"retrying": "جارٍ إعادة المحاولة",
"retrying": "جاري إعادة المحاولة...",
"failedToLoad": "فشل تحميل البيانات",
"noDataShort": "لا بيانات",
"upstreamUnavailable": "واجهة API المصدر غير متاحة — ستتم إعادة المحاولة تلقائياً",
@@ -1797,7 +1809,6 @@
"close": "إغلاق",
"currentVariant": "(الحالي)",
"retry": "Retry",
"retrying": "جاري إعادة المحاولة...",
"refresh": "Refresh"
}
}

View File

@@ -1160,7 +1160,19 @@
"retry": "Retry",
"notLive": "{{name}} is not currently live",
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
"openOnYouTube": "Open on YouTube"
"openOnYouTube": "Open on YouTube",
"manage": "Kanäle verwalten",
"addChannel": "Kanal hinzufügen",
"remove": "Entfernen",
"youtubeHandle": "YouTube-Handle (z. B. @Channel)",
"displayName": "Anzeigename (optional)",
"openPanelSettings": "Panelanzeige-Einstellungen",
"channelSettings": "Kanaleinstellungen",
"save": "Speichern",
"cancel": "Abbrechen",
"confirmDelete": "Diesen Kanal löschen?",
"confirmTitle": "Bestätigen",
"restoreDefaults": "Standardkanäle wiederherstellen"
}
},
"popups": {
@@ -1749,7 +1761,7 @@
"noData": "Keine Daten verfügbar",
"updated": "Gerade aktualisiert",
"ago": "vor {{time}}",
"retrying": "Erneuter Versuch…",
"retrying": "Wird wiederholt...",
"failedToLoad": "Fehler beim Laden der Daten",
"noDataShort": "Keine Daten",
"noDataAvailable": "Keine Daten verfügbar",
@@ -1797,7 +1809,6 @@
"close": "Schließen",
"currentVariant": "(aktuell)",
"retry": "Retry",
"retrying": "Wird wiederholt...",
"refresh": "Refresh"
}
}

View File

@@ -66,7 +66,8 @@
"filterSources": "Filter sources...",
"sourcesEnabled": "{{enabled}}/{{total}} enabled",
"finance": "FINANCE",
"toggleTheme": "Toggle dark/light mode"
"toggleTheme": "Toggle dark/light mode",
"panelDisplayCaption": "Choose which panels to show on the dashboard"
},
"panels": {
"liveNews": "Live News",
@@ -1177,7 +1178,19 @@
"retry": "Retry",
"notLive": "{{name}} is not currently live",
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
"openOnYouTube": "Open on YouTube"
"openOnYouTube": "Open on YouTube",
"manage": "Manage channels",
"addChannel": "Add channel",
"remove": "Remove",
"youtubeHandle": "YouTube handle (e.g. @Channel)",
"displayName": "Display name (optional)",
"openPanelSettings": "Panel display settings",
"channelSettings": "Channel Settings",
"save": "Save",
"cancel": "Cancel",
"confirmDelete": "Delete this channel?",
"confirmTitle": "Confirm",
"restoreDefaults": "Restore default channels"
}
},
"popups": {

View File

@@ -1160,7 +1160,19 @@
"retry": "Retry",
"notLive": "{{name}} is not currently live",
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
"openOnYouTube": "Open on YouTube"
"openOnYouTube": "Open on YouTube",
"manage": "Gestionar canales",
"addChannel": "Añadir canal",
"remove": "Eliminar",
"youtubeHandle": "Handle de YouTube (ej. @Channel)",
"displayName": "Nombre para mostrar (opcional)",
"openPanelSettings": "Configuración de visualización del panel",
"channelSettings": "Configuración del canal",
"save": "Guardar",
"cancel": "Cancelar",
"confirmDelete": "Eliminar este canal?",
"confirmTitle": "Confirmar",
"restoreDefaults": "Restaurar canales predeterminados"
}
},
"popups": {
@@ -1749,7 +1761,7 @@
"noData": "No hay datos disponibles",
"updated": "Actualizado ahora",
"ago": "hace {{time}}",
"retrying": "Reintentando",
"retrying": "Reintentando...",
"failedToLoad": "Error al cargar los datos",
"noDataShort": "Sin datos",
"noDataAvailable": "No hay datos disponibles",
@@ -1797,7 +1809,6 @@
"close": "Cerrar",
"currentVariant": "(actual)",
"retry": "Retry",
"retrying": "Reintentando...",
"refresh": "Refresh"
}
}

View File

@@ -1160,7 +1160,19 @@
"retry": "Retry",
"notLive": "{{name}} is not currently live",
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
"openOnYouTube": "Open on YouTube"
"openOnYouTube": "Open on YouTube",
"manage": "Gérer les chaînes",
"addChannel": "Ajouter une chaîne",
"remove": "Supprimer",
"youtubeHandle": "Handle YouTube (ex. @Channel)",
"displayName": "Nom d'affichage (optionnel)",
"openPanelSettings": "Paramètres d'affichage du panneau",
"channelSettings": "Paramètres de la chaîne",
"save": "Enregistrer",
"cancel": "Annuler",
"confirmDelete": "Supprimer cette chaîne ?",
"confirmTitle": "Confirmer",
"restoreDefaults": "Restaurer les chaînes par défaut"
}
},
"popups": {
@@ -1749,7 +1761,7 @@
"noData": "Aucune donnée disponible",
"updated": "Mis à jour à l'instant",
"ago": "il y a {{time}}",
"retrying": "Nouvelle tentative",
"retrying": "Nouvelle tentative...",
"failedToLoad": "Échec du chargement des données",
"noDataShort": "Aucune donnée",
"upstreamUnavailable": "API source indisponible — nouvelle tentative automatique",
@@ -1797,7 +1809,6 @@
"close": "Fermer",
"currentVariant": "(actuel)",
"retry": "Retry",
"retrying": "Nouvelle tentative...",
"refresh": "Refresh"
}
}

View File

@@ -1160,7 +1160,19 @@
"retry": "Retry",
"notLive": "{{name}} is not currently live",
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
"openOnYouTube": "Open on YouTube"
"openOnYouTube": "Open on YouTube",
"manage": "Gestisci canali",
"addChannel": "Aggiungi canale",
"remove": "Rimuovi",
"youtubeHandle": "Handle YouTube (es. @Channel)",
"displayName": "Nome visualizzato (opzionale)",
"openPanelSettings": "Impostazioni visualizzazione pannello",
"channelSettings": "Impostazioni canale",
"save": "Salva",
"cancel": "Annulla",
"confirmDelete": "Eliminare questo canale?",
"confirmTitle": "Conferma",
"restoreDefaults": "Ripristina canali predefiniti"
}
},
"popups": {
@@ -1749,7 +1761,7 @@
"noData": "Nessun dato disponibile",
"updated": "Aggiornato ora",
"ago": "{{time}} fa",
"retrying": "Nuovo tentativo",
"retrying": "Nuovo tentativo...",
"failedToLoad": "Errore nel caricamento dei dati",
"noDataShort": "Nessun dato",
"noDataAvailable": "Nessun dato disponibile",
@@ -1797,7 +1809,6 @@
"close": "Chiudi",
"currentVariant": "(corrente)",
"retry": "Retry",
"retrying": "Nuovo tentativo...",
"refresh": "Refresh"
}
}

View File

@@ -89,7 +89,8 @@
"filterSources": "ソースを絞り込み...",
"sourcesEnabled": "{{enabled}}/{{total}} 有効",
"finance": "金融",
"toggleTheme": "ダーク/ライトモード切替"
"toggleTheme": "ダーク/ライトモード切替",
"panelDisplayCaption": "ダッシュボードに表示するパネルを選択"
},
"panels": {
"liveNews": "ライブニュース",
@@ -1160,7 +1161,19 @@
"retry": "Retry",
"notLive": "{{name}} is not currently live",
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
"openOnYouTube": "Open on YouTube"
"openOnYouTube": "Open on YouTube",
"manage": "チャンネル管理",
"addChannel": "チャンネルを追加",
"remove": "削除",
"youtubeHandle": "YouTube ハンドル(例: @Channel",
"displayName": "表示名(任意)",
"openPanelSettings": "表示パネル設定",
"channelSettings": "チャンネル設定",
"save": "保存",
"cancel": "キャンセル",
"confirmDelete": "このチャンネルを削除しますか?",
"confirmTitle": "確認",
"restoreDefaults": "デフォルトのチャンネルを復元"
}
},
"popups": {
@@ -1750,7 +1763,7 @@
"noDataAvailable": "データなし",
"updated": "たった今更新",
"ago": "{{time}}前",
"retrying": "再試行中",
"retrying": "再試行中...",
"failedToLoad": "データの読み込みに失敗",
"noDataShort": "データなし",
"upstreamUnavailable": "上流APIが利用不可 — 自動リトライ予定",
@@ -1797,7 +1810,6 @@
"close": "閉じる",
"currentVariant": "(現在)",
"retry": "Retry",
"retrying": "再試行中...",
"refresh": "Refresh"
}
}

View File

@@ -1068,7 +1068,19 @@
"retry": "Retry",
"notLive": "{{name}} is not currently live",
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
"openOnYouTube": "Open on YouTube"
"openOnYouTube": "Open on YouTube",
"manage": "Kanalen beheren",
"addChannel": "Kanaal toevoegen",
"remove": "Verwijderen",
"youtubeHandle": "YouTube-handle (bijv. @Channel)",
"displayName": "Weergavenaam (optioneel)",
"openPanelSettings": "Paneelweergave-instellingen",
"channelSettings": "Kanaalinstellingen",
"save": "Opslaan",
"cancel": "Annuleren",
"confirmDelete": "Dit kanaal verwijderen?",
"confirmTitle": "Bevestigen",
"restoreDefaults": "Standaardkanalen herstellen"
}
},
"popups": {
@@ -1655,7 +1667,7 @@
"loading": "Laden...",
"error": "Er is een fout opgetreden",
"updated": "Bijgewerkt: {{time}}",
"retrying": "Opnieuw proberen",
"retrying": "Opnieuw proberen...",
"failedToLoad": "Laden van gegevens mislukt",
"noDataShort": "Geen gegevens",
"noDataAvailable": "Geen gegevens beschikbaar",
@@ -1705,7 +1717,6 @@
"close": "Sluiten",
"currentVariant": "(huidig)",
"retry": "Retry",
"retrying": "Opnieuw proberen...",
"refresh": "Refresh"
},
"header": {

View File

@@ -1160,7 +1160,19 @@
"retry": "Retry",
"notLive": "{{name}} is not currently live",
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
"openOnYouTube": "Open on YouTube"
"openOnYouTube": "Open on YouTube",
"manage": "Zarządzaj kanałami",
"addChannel": "Dodaj kanał",
"remove": "Usuń",
"youtubeHandle": "Handle YouTube (np. @Channel)",
"displayName": "Nazwa wyświetlana (opcjonalnie)",
"openPanelSettings": "Ustawienia wyświetlania panelu",
"channelSettings": "Ustawienia kanału",
"save": "Zapisz",
"cancel": "Anuluj",
"confirmDelete": "Usunąć ten kanał?",
"confirmTitle": "Potwierdź",
"restoreDefaults": "Przywróć domyślne kanały"
}
},
"popups": {
@@ -1749,7 +1761,7 @@
"noData": "Brak danych",
"updated": "Zaktualizowano przed chwilą",
"ago": "{{time}} temu",
"retrying": "Ponowna próba…",
"retrying": "Ponawiam próbę...",
"failedToLoad": "Nie udało się załadować danych",
"noDataShort": "Brak danych",
"noDataAvailable": "Brak dostępnych danych",
@@ -1797,7 +1809,6 @@
"close": "Zamknij",
"currentVariant": "(bieżący)",
"retry": "Retry",
"retrying": "Ponawiam próbę...",
"refresh": "Refresh"
}
}

View File

@@ -1068,7 +1068,19 @@
"retry": "Retry",
"notLive": "{{name}} is not currently live",
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
"openOnYouTube": "Open on YouTube"
"openOnYouTube": "Open on YouTube",
"manage": "Gerir canais",
"addChannel": "Adicionar canal",
"remove": "Remover",
"youtubeHandle": "Handle do YouTube (ex.: @Channel)",
"displayName": "Nome de exibição (opcional)",
"openPanelSettings": "Configurações de exibição do painel",
"channelSettings": "Configurações do canal",
"save": "Salvar",
"cancel": "Cancelar",
"confirmDelete": "Excluir este canal?",
"confirmTitle": "Confirmar",
"restoreDefaults": "Restaurar canais padrão"
}
},
"popups": {
@@ -1655,7 +1667,7 @@
"loading": "Carregando...",
"error": "Ocorreu um erro",
"updated": "Atualizado: {{time}}",
"retrying": "Tentando novamente",
"retrying": "Tentando novamente...",
"failedToLoad": "Falha ao carregar os dados",
"noDataShort": "Sem dados",
"noDataAvailable": "Nenhum dado disponível",
@@ -1705,7 +1717,6 @@
"close": "Fechar",
"currentVariant": "(atual)",
"retry": "Retry",
"retrying": "Tentando novamente...",
"refresh": "Refresh"
},
"header": {

View File

@@ -1160,7 +1160,19 @@
"retry": "Retry",
"notLive": "{{name}} is not currently live",
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
"openOnYouTube": "Open on YouTube"
"openOnYouTube": "Open on YouTube",
"manage": "Управление каналами",
"addChannel": "Добавить канал",
"remove": "Удалить",
"youtubeHandle": "YouTube- handle (напр. @Channel)",
"displayName": "Отображаемое имя (необяз.)",
"openPanelSettings": "Настройки отображения панели",
"channelSettings": "Настройки канала",
"save": "Сохранить",
"cancel": "Отмена",
"confirmDelete": "Удалить этот канал?",
"confirmTitle": "Подтверждение",
"restoreDefaults": "Восстановить каналы по умолчанию"
}
},
"popups": {
@@ -1750,7 +1762,7 @@
"noDataAvailable": "Данные недоступны",
"updated": "Обновлено только что",
"ago": "{{time}} назад",
"retrying": "Повторная попытка",
"retrying": "Повторная попытка...",
"failedToLoad": "Не удалось загрузить данные",
"noDataShort": "Нет данных",
"upstreamUnavailable": "Внешний API недоступен — автоматическая повторная попытка",
@@ -1797,7 +1809,6 @@
"close": "Закрыть",
"currentVariant": "(текущий)",
"retry": "Retry",
"retrying": "Повторная попытка...",
"refresh": "Refresh"
}
}

View File

@@ -1068,7 +1068,19 @@
"retry": "Retry",
"notLive": "{{name}} is not currently live",
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
"openOnYouTube": "Open on YouTube"
"openOnYouTube": "Open on YouTube",
"manage": "Hantera kanaler",
"addChannel": "Lägg till kanal",
"remove": "Ta bort",
"youtubeHandle": "YouTube-handtag (t.ex. @Channel)",
"displayName": "Visningsnamn (valfritt)",
"openPanelSettings": "Panelvisningsinställningar",
"channelSettings": "Kanalinställningar",
"save": "Spara",
"cancel": "Avbryt",
"confirmDelete": "Ta bort denna kanal?",
"confirmTitle": "Bekräfta",
"restoreDefaults": "Återställ standardkanaler"
}
},
"popups": {
@@ -1655,7 +1667,7 @@
"loading": "Laddar...",
"error": "Ett fel inträffade",
"updated": "Uppdaterad: {{time}}",
"retrying": "Försöker igen",
"retrying": "Försöker igen...",
"failedToLoad": "Kunde inte ladda data",
"noDataShort": "Inga data",
"noDataAvailable": "Inga data tillgängliga",
@@ -1705,7 +1717,6 @@
"close": "Stäng",
"currentVariant": "(aktuell)",
"retry": "Retry",
"retrying": "Försöker igen...",
"refresh": "Refresh"
},
"header": {

View File

@@ -1137,7 +1137,19 @@
"retry": "ลองใหม่",
"notLive": "{{name}} ไม่ได้ถ่ายทอดสดอยู่ในขณะนี้",
"cannotEmbed": "ไม่สามารถฝัง {{name}} ในแอปนี้ได้ (YouTube {{code}})",
"openOnYouTube": "เปิดบน YouTube"
"openOnYouTube": "เปิดบน YouTube",
"manage": "จัดการช่อง",
"addChannel": "เพิ่มช่อง",
"remove": "ลบ",
"youtubeHandle": "YouTube handle (เช่น @Channel)",
"displayName": "ชื่อที่แสดง (ไม่บังคับ)",
"openPanelSettings": "การตั้งค่าการแสดงผลแผง",
"channelSettings": "การตั้งค่าช่อง",
"save": "บันทึก",
"cancel": "ยกเลิก",
"confirmDelete": "ลบช่องนี้หรือไม่?",
"confirmTitle": "ยืนยัน",
"restoreDefaults": "คืนค่าช่องเริ่มต้น"
}
},
"popups": {
@@ -1727,7 +1739,7 @@
"noDataAvailable": "ไม่มีข้อมูล",
"updated": "อัปเดตเมื่อสักครู่",
"ago": "{{time}} ที่แล้ว",
"retrying": "กำลังลองใหม่",
"retrying": "กำลังลองใหม่...",
"failedToLoad": "โหลดข้อมูลไม่สำเร็จ",
"noDataShort": "ไม่มีข้อมูล",
"upstreamUnavailable": "API ต้นทางไม่พร้อมใช้งาน — จะลองใหม่อัตโนมัติ",
@@ -1774,7 +1786,6 @@
"close": "ปิด",
"currentVariant": "(ปัจจุบัน)",
"retry": "ลองใหม่",
"retrying": "กำลังลองใหม่...",
"refresh": "รีเฟรช"
}
}

View File

@@ -1160,7 +1160,19 @@
"retry": "Retry",
"notLive": "{{name}} is not currently live",
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
"openOnYouTube": "Open on YouTube"
"openOnYouTube": "Open on YouTube",
"manage": "Kanalları yönet",
"addChannel": "Kanal ekle",
"remove": "Kaldır",
"youtubeHandle": "YouTube tanıtıcı (örn. @Channel)",
"displayName": "Görünen ad (isteğe bağlı)",
"openPanelSettings": "Panel görüntü ayarları",
"channelSettings": "Kanal ayarları",
"save": "Kaydet",
"cancel": "İptal",
"confirmDelete": "Bu kanal silinsin mi?",
"confirmTitle": "Onayla",
"restoreDefaults": "Varsayılan kanalları geri yükle"
}
},
"popups": {
@@ -1750,7 +1762,7 @@
"noDataAvailable": "Veri mevcut degil",
"updated": "Az once guncellendi",
"ago": "{{time}} once",
"retrying": "Yeniden deneniyor",
"retrying": "Tekrar deneniyor...",
"failedToLoad": "Veri yuklenemedi",
"noDataShort": "Veri yok",
"upstreamUnavailable": "Ust kaynak API'si kullanilamiyor — otomatik yeniden denenecek",
@@ -1797,7 +1809,6 @@
"close": "Kapat",
"currentVariant": "(mevcut)",
"retry": "Retry",
"retrying": "Tekrar deneniyor...",
"refresh": "Refresh"
}
}

View File

@@ -1137,7 +1137,19 @@
"retry": "Thử lại",
"notLive": "{{name}} hiện không phát trực tiếp",
"cannotEmbed": "{{name}} không thể nhúng trong ứng dụng này (YouTube {{code}})",
"openOnYouTube": "Mở trên YouTube"
"openOnYouTube": "Mở trên YouTube",
"manage": "Quản lý kênh",
"addChannel": "Thêm kênh",
"remove": "Xóa",
"youtubeHandle": "Handle YouTube (vd: @Channel)",
"displayName": "Tên hiển thị (tùy chọn)",
"openPanelSettings": "Cài đặt hiển thị bảng",
"channelSettings": "Cài đặt kênh",
"save": "Lưu",
"cancel": "Hủy",
"confirmDelete": "Xóa kênh này?",
"confirmTitle": "Xác nhận",
"restoreDefaults": "Khôi phục kênh mặc định"
}
},
"popups": {
@@ -1727,7 +1739,7 @@
"noDataAvailable": "Không có dữ liệu",
"updated": "Vừa cập nhật",
"ago": "{{time}} trước",
"retrying": "Đang thử lại",
"retrying": "Đang thử lại...",
"failedToLoad": "Không thể tải dữ liệu",
"noDataShort": "Không có dữ liệu",
"upstreamUnavailable": "API nguồn không khả dụng — sẽ tự động thử lại",
@@ -1774,7 +1786,6 @@
"close": "Đóng",
"currentVariant": "(hiện tại)",
"retry": "Thử lại",
"retrying": "Đang thử lại...",
"refresh": "Làm mới"
}
}

View File

@@ -1160,7 +1160,19 @@
"retry": "Retry",
"notLive": "{{name}} is not currently live",
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
"openOnYouTube": "Open on YouTube"
"openOnYouTube": "Open on YouTube",
"manage": "管理频道",
"addChannel": "添加频道",
"remove": "删除",
"youtubeHandle": "YouTube 句柄(如 @Channel",
"displayName": "显示名称(可选)",
"openPanelSettings": "面板显示设置",
"channelSettings": "频道设置",
"save": "保存",
"cancel": "取消",
"confirmDelete": "确定要删除此频道吗?",
"confirmTitle": "确认",
"restoreDefaults": "恢复默认频道"
}
},
"popups": {
@@ -1750,7 +1762,7 @@
"noDataAvailable": "无可用数据",
"updated": "刚刚更新",
"ago": "{{time}}前",
"retrying": "正在重试",
"retrying": "正在重试...",
"failedToLoad": "加载数据失败",
"noDataShort": "无数据",
"upstreamUnavailable": "上游API不可用 — 将自动重试",
@@ -1797,7 +1809,6 @@
"close": "关闭",
"currentVariant": "(当前)",
"retry": "Retry",
"retrying": "正在重试...",
"refresh": "Refresh"
}
}

View File

@@ -148,14 +148,32 @@ requestAnimationFrame(() => {
// Clear stale settings-open flag (survives ungraceful shutdown)
localStorage.removeItem('wm-settings-open');
const app = new App('app');
app
.init()
.then(() => {
// Clear the one-shot guard after a successful boot so future stale-chunk incidents can recover.
clearChunkReloadGuard(chunkReloadStorageKey);
})
.catch(console.error);
// Standalone windows: ?settings=1 = panel display settings, ?live-channels=1 = channel management
// Both need i18n initialized so t() does not return undefined.
const urlParams = new URL(location.href).searchParams;
if (urlParams.get('settings') === '1') {
void Promise.all([import('./services/i18n'), import('./settings-window')]).then(
async ([i18n, m]) => {
await i18n.initI18n();
m.initSettingsWindow();
}
);
} else if (urlParams.get('live-channels') === '1') {
void Promise.all([import('./services/i18n'), import('./live-channels-window')]).then(
async ([i18n, m]) => {
await i18n.initI18n();
m.initLiveChannelsWindow();
}
);
} else {
const app = new App('app');
app
.init()
.then(() => {
clearChunkReloadGuard(chunkReloadStorageKey);
})
.catch(console.error);
}
// Debug helpers for geo-convergence testing (remove in production)
(window as unknown as Record<string, unknown>).geoDebug = {

116
src/settings-window.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* Standalone settings window: panel toggles only.
* Loaded when the app is opened with ?settings=1 (e.g. from the main window's Settings button).
*/
import type { PanelConfig } from '@/types';
import { DEFAULT_PANELS, STORAGE_KEYS } from '@/config';
import { loadFromStorage, saveToStorage, isMobileDevice } from '@/utils';
import { t } from '@/services/i18n';
import { escapeHtml } from '@/utils/sanitize';
import { isDesktopRuntime } from '@/services/runtime';
const INTEL_FINDINGS_KEY = 'worldmonitor-intel-findings';
function getLocalizedPanelName(panelKey: string, fallback: string): string {
if (panelKey === 'runtime-config') {
return t('modals.runtimeConfig.title');
}
const key = panelKey.replace(/-([a-z])/g, (_match, group: string) => group.toUpperCase());
const lookup = `panels.${key}`;
const localized = t(lookup);
return localized === lookup ? fallback : localized;
}
export function initSettingsWindow(): void {
const appEl = document.getElementById('app');
if (!appEl) return;
// This window shows only "which panels to display" (panel display settings).
document.title = `${t('header.settings')} - World Monitor`;
let panelSettings = loadFromStorage<Record<string, PanelConfig>>(
STORAGE_KEYS.panels,
DEFAULT_PANELS
);
const isDesktopApp = isDesktopRuntime();
const isMobile = isMobileDevice();
function getFindingsEnabled(): boolean {
return localStorage.getItem(INTEL_FINDINGS_KEY) !== 'hidden';
}
function setFindingsEnabled(enabled: boolean): void {
if (enabled) {
localStorage.removeItem(INTEL_FINDINGS_KEY);
} else {
localStorage.setItem(INTEL_FINDINGS_KEY, 'hidden');
}
}
function render(): void {
const panelEntries = Object.entries(panelSettings).filter(
([key]) => key !== 'runtime-config' || isDesktopApp
);
const panelHtml = panelEntries
.map(
([key, panel]) => `
<div class="panel-toggle-item ${panel.enabled ? 'active' : ''}" data-panel="${key}">
<div class="panel-toggle-checkbox">${panel.enabled ? '✓' : ''}</div>
<span class="panel-toggle-label">${getLocalizedPanelName(key, panel.name)}</span>
</div>
`
)
.join('');
const findingsHtml = isMobile
? ''
: `
<div class="panel-toggle-item ${getFindingsEnabled() ? 'active' : ''}" data-panel="intel-findings">
<div class="panel-toggle-checkbox">${getFindingsEnabled() ? '✓' : ''}</div>
<span class="panel-toggle-label">Intelligence Findings</span>
</div>
`;
const grid = document.getElementById('panelToggles');
if (grid) {
grid.innerHTML = panelHtml + findingsHtml;
grid.querySelectorAll('.panel-toggle-item').forEach((item) => {
item.addEventListener('click', () => {
const panelKey = (item as HTMLElement).dataset.panel!;
if (panelKey === 'intel-findings') {
const next = !getFindingsEnabled();
setFindingsEnabled(next);
render();
return;
}
const config = panelSettings[panelKey];
if (config) {
config.enabled = !config.enabled;
saveToStorage(STORAGE_KEYS.panels, panelSettings);
render();
}
});
});
}
}
appEl.innerHTML = `
<div class="settings-window-shell">
<div class="settings-window-header">
<div class="settings-window-header-text">
<span class="settings-window-title">${escapeHtml(t('header.settings'))}</span>
<p class="settings-window-caption">${escapeHtml(t('header.panelDisplayCaption'))}</p>
</div>
<button type="button" class="modal-close" id="settingsWindowClose">×</button>
</div>
<div class="panel-toggle-grid" id="panelToggles"></div>
</div>
`;
document.getElementById('settingsWindowClose')?.addEventListener('click', () => {
window.close();
});
render();
}

View File

@@ -1187,8 +1187,10 @@ canvas,
}
/* Live News Panel */
.live-news-switcher {
.live-news-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 4px;
padding: 6px 8px;
background: var(--darken-heavy);
@@ -1196,6 +1198,21 @@ canvas,
flex-shrink: 0;
}
.live-news-toolbar .live-news-switcher {
flex: 1;
min-width: 0;
}
.live-news-switcher {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 0;
background: transparent;
border: none;
min-width: 0;
}
.live-channel-btn {
padding: 4px 8px;
background: transparent;
@@ -1236,6 +1253,277 @@ canvas,
border-style: dashed;
}
.live-channel-btn[draggable="true"] {
cursor: grab;
}
.live-channel-btn.live-channel-dragging {
opacity: 0.6;
cursor: grabbing;
}
/* Live News Channel settings button (same style as webcam view buttons) */
.live-news-settings-btn {
padding: 4px 8px;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
font-family: inherit;
font-size: 10px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.live-news-settings-btn:hover {
border-color: var(--text-dim);
color: var(--text);
}
/* Channel management list: same layout as LIVE panel channel switcher */
.live-news-manage-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
/* Each row = same style as .live-channel-btn */
.live-news-manage-row {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
font-family: inherit;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.75px;
white-space: nowrap;
cursor: grab;
transition: all 0.2s;
}
.live-news-manage-row:active {
cursor: grabbing;
}
.live-news-manage-row:hover {
border-color: var(--text-dim);
color: var(--text);
}
.live-news-manage-row-dragging {
opacity: 0.6;
cursor: grabbing;
}
.live-news-manage-remove {
padding: 4px 8px;
font-size: 10px;
min-height: auto;
color: var(--red);
background: transparent;
border: 1px solid var(--border);
cursor: pointer;
border-radius: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.live-news-manage-remove:hover {
text-decoration: underline;
border-color: var(--red);
}
.live-news-manage-remove-in-form {
font-weight: 600;
border-color: var(--red);
color: var(--red);
}
.live-news-manage-remove-in-form:hover {
background: rgba(255, 80, 80, 0.15);
}
.live-news-manage-row-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.live-news-manage-edit {
padding: 4px 8px;
font-size: 10px;
min-height: auto;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
cursor: pointer;
border-radius: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.live-news-manage-edit:hover {
color: var(--text);
border-color: var(--text-dim);
}
.live-news-manage-row-editing {
cursor: default;
flex-wrap: wrap;
gap: 8px;
padding: 8px 10px;
background: transparent;
border: 1px solid var(--border);
white-space: normal;
}
.live-news-manage-row-editing .live-news-manage-edit-handle,
.live-news-manage-row-editing .live-news-manage-edit-name {
padding: 10px 12px;
font-size: 14px;
min-width: 160px;
min-height: 40px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 4px;
}
.live-news-manage-save,
.live-news-manage-cancel {
padding: 8px 16px;
font-size: 13px;
min-height: 40px;
border: 1px solid var(--border);
cursor: pointer;
background: var(--bg);
color: var(--text);
border-radius: 4px;
}
.live-news-manage-save:hover {
border-color: var(--green);
color: var(--green);
}
.live-news-manage-cancel:hover {
border-color: var(--text-dim);
}
.live-news-manage-add-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.live-news-manage-add-title {
font-size: 14px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text);
}
.live-news-manage-add {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
}
.live-news-manage-add-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.live-news-manage-add-label {
font-size: 12px;
font-weight: 600;
color: var(--text);
}
.live-news-manage-handle,
.live-news-manage-name {
padding: 10px 12px;
font-size: 14px;
min-height: 44px;
width: 200px;
max-width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 4px;
}
.live-news-manage-add-btn {
padding: 10px 18px;
font-size: 14px;
min-height: 44px;
background: var(--border);
border: 1px solid var(--border);
color: var(--text);
cursor: pointer;
border-radius: 4px;
}
.live-news-manage-add-btn:hover {
background: var(--text-dim);
color: var(--bg);
}
/* Standalone live channels window (?live-channels=1) */
.live-channels-window-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg);
color: var(--text);
}
.live-channels-window-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--darken-heavy);
flex-shrink: 0;
}
.live-channels-window-shell .modal-close {
padding: 10px 14px;
font-size: 20px;
min-width: 44px;
min-height: 44px;
border-radius: 4px;
}
.live-channels-window-toolbar {
margin-bottom: 12px;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.live-news-manage-restore-defaults {
padding: 8px 14px;
font-size: 13px;
min-height: 40px;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
cursor: pointer;
border-radius: 4px;
}
.live-news-manage-restore-defaults:hover {
color: var(--text);
border-color: var(--text-dim);
}
.live-channels-window-title {
font-size: 16px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
}
.live-channels-window-content {
padding: 16px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.live-channels-window-shell .live-news-manage-list {
min-height: 0;
overflow-y: auto;
align-content: flex-start;
}
.live-channels-window-shell .live-news-manage-add-section {
margin-top: 20px;
}
.live-offline {
display: flex;
flex-direction: column;
@@ -4586,6 +4874,59 @@ a.prediction-link:hover {
cursor: pointer;
}
.confirm-modal-message {
margin: 0 0 16px;
font-size: 14px;
line-height: 1.5;
}
.confirm-modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* Standalone settings window (?settings=1) */
.settings-window-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg);
color: var(--text);
}
.settings-window-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--darken-heavy);
flex-shrink: 0;
}
.settings-window-header-text {
flex: 1;
min-width: 0;
}
.settings-window-title {
font-size: 14px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
display: block;
}
.settings-window-caption {
margin: 4px 0 0;
font-size: 12px;
font-weight: normal;
text-transform: none;
letter-spacing: 0;
color: var(--text-dim);
line-height: 1.3;
}
.settings-window-shell .panel-toggle-grid {
padding: 16px;
flex: 1;
}
.panel-toggle-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);

View File

@@ -632,6 +632,7 @@ export default defineConfig({
input: {
main: resolve(__dirname, 'index.html'),
settings: resolve(__dirname, 'settings.html'),
liveChannels: resolve(__dirname, 'live-channels.html'),
},
output: {
manualChunks(id) {