From 6271fafd40269b1879c7a69f94fee4e901fafc9b Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Mon, 23 Feb 2026 22:51:44 +0000 Subject: [PATCH] feat(live): custom channel management with review fixes (#282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 Co-authored-by: Cursor --- live-channels.html | 14 ++ src-tauri/capabilities/default.json | 2 +- src-tauri/src/main.rs | 55 +++++ src/App.ts | 20 ++ src/components/LiveNewsPanel.ts | 181 ++++++++++++++- src/config/variants/base.ts | 1 + src/live-channels-main.ts | 14 ++ src/live-channels-window.ts | 334 +++++++++++++++++++++++++++ src/locales/ar.json | 17 +- src/locales/de.json | 17 +- src/locales/en.json | 17 +- src/locales/es.json | 17 +- src/locales/fr.json | 17 +- src/locales/it.json | 17 +- src/locales/ja.json | 20 +- src/locales/nl.json | 17 +- src/locales/pl.json | 17 +- src/locales/pt.json | 17 +- src/locales/ru.json | 17 +- src/locales/sv.json | 17 +- src/locales/th.json | 17 +- src/locales/tr.json | 17 +- src/locales/vi.json | 17 +- src/locales/zh.json | 17 +- src/main.ts | 34 ++- src/settings-window.ts | 116 ++++++++++ src/styles/main.css | 343 +++++++++++++++++++++++++++- vite.config.ts | 1 + 28 files changed, 1321 insertions(+), 69 deletions(-) create mode 100644 live-channels.html create mode 100644 src/live-channels-main.ts create mode 100644 src/live-channels-window.ts create mode 100644 src/settings-window.ts diff --git a/live-channels.html b/live-channels.html new file mode 100644 index 000000000..6db805c0d --- /dev/null +++ b/live-channels.html @@ -0,0 +1,14 @@ + + + + + + + Channel management - World Monitor + + + +
+ + + diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e06ae9702..d0551d738 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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"] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c926d0a31..fc3772261 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, +) -> 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) -> 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> { 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 ]) diff --git a/src/App.ts b/src/App.ts index 10592c64c..35a943a5a 100644 --- a/src/App.ts +++ b/src/App.ts @@ -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; + 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'); }); diff --git a/src/components/LiveNewsPanel.ts b/src/components/LiveNewsPanel.ts index db080ab69..fae9d8d69 100644 --- a/src/components/LiveNewsPanel.ts +++ b/src/components/LiveNewsPanel.ts @@ -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; +} + +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(STORAGE_KEYS.liveChannels, DEFAULT_STORED); + const order = stored.order?.length ? stored.order : DEFAULT_STORED.order; + const channelMap = new Map(); + 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(); + for (const c of [...FULL_LIVE_CHANNELS, ...TECH_LIVE_CHANNELS]) builtinNames.set(c.id, c.name); + const displayNameOverrides: Record = {}; + 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 | 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 = + ''; + openBtn.addEventListener('click', () => { + if (isDesktopRuntime()) { + void invokeTauri('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('.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 { @@ -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); diff --git a/src/config/variants/base.ts b/src/config/variants/base.ts index 139eac741..f0b17c88a 100644 --- a/src/config/variants/base.ts +++ b/src/config/variants/base.ts @@ -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 diff --git a/src/live-channels-main.ts b/src/live-channels-main.ts new file mode 100644 index 000000000..15e151dd7 --- /dev/null +++ b/src/live-channels-main.ts @@ -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 { + await initI18n(); + initLiveChannelsWindow(); +} + +void main().catch(console.error); diff --git a/src/live-channels-window.ts b/src/live-channels-window.ts new file mode 100644 index 000000000..9a90f0eb1 --- /dev/null +++ b/src/live-channels-window.ts @@ -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 = ` + + `; + 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('.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 = ` +
+
+ ${escapeHtml(t('components.liveNews.manage') ?? 'Channel management')} +
+
+
+ +
+
+
+ ${escapeHtml(t('components.liveNews.addChannel') ?? 'Add channel')} +
+
+ + +
+
+ + +
+ +
+
+
+
+ `; + + 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 = ''; + }); +} diff --git a/src/locales/ar.json b/src/locales/ar.json index a6195633e..ea62e72f4 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -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" } } diff --git a/src/locales/de.json b/src/locales/de.json index 3b9b9b51f..d0a87b41d 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -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" } } diff --git a/src/locales/en.json b/src/locales/en.json index 2d0e5c183..293dcdda2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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": { diff --git a/src/locales/es.json b/src/locales/es.json index 9b8b8a7ce..8478c7976 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -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" } } diff --git a/src/locales/fr.json b/src/locales/fr.json index c5258ec50..5460016a4 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -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" } } diff --git a/src/locales/it.json b/src/locales/it.json index 8bc4d3c51..4c96d720b 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -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" } } diff --git a/src/locales/ja.json b/src/locales/ja.json index 4c225b430..0ef51eff1 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -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" } } diff --git a/src/locales/nl.json b/src/locales/nl.json index a94c73e3a..799d55fe2 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -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": { diff --git a/src/locales/pl.json b/src/locales/pl.json index 3777c52a3..c68c1233f 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -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" } } diff --git a/src/locales/pt.json b/src/locales/pt.json index 4e0b6dd0b..1c696926b 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -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": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 9fbdf925d..8d99cde0c 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -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" } } diff --git a/src/locales/sv.json b/src/locales/sv.json index e679ccff9..53189f11a 100644 --- a/src/locales/sv.json +++ b/src/locales/sv.json @@ -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": { diff --git a/src/locales/th.json b/src/locales/th.json index d392cde03..95f9bc8b1 100644 --- a/src/locales/th.json +++ b/src/locales/th.json @@ -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": "รีเฟรช" } } diff --git a/src/locales/tr.json b/src/locales/tr.json index 783727905..e2416e71d 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -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" } } diff --git a/src/locales/vi.json b/src/locales/vi.json index 0418b1c43..d45ce02ca 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -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" } } diff --git a/src/locales/zh.json b/src/locales/zh.json index 16a86f2a0..ed7684c8b 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -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" } } diff --git a/src/main.ts b/src/main.ts index 7940f4b81..4832a0b63 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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).geoDebug = { diff --git a/src/settings-window.ts b/src/settings-window.ts new file mode 100644 index 000000000..283480e90 --- /dev/null +++ b/src/settings-window.ts @@ -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>( + 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]) => ` +
+
${panel.enabled ? '✓' : ''}
+ ${getLocalizedPanelName(key, panel.name)} +
+ ` + ) + .join(''); + + const findingsHtml = isMobile + ? '' + : ` +
+
${getFindingsEnabled() ? '✓' : ''}
+ Intelligence Findings +
+ `; + + 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 = ` +
+
+
+ ${escapeHtml(t('header.settings'))} +

${escapeHtml(t('header.panelDisplayCaption'))}

+
+ +
+
+
+ `; + + document.getElementById('settingsWindowClose')?.addEventListener('click', () => { + window.close(); + }); + + render(); +} diff --git a/src/styles/main.css b/src/styles/main.css index 35e6240f4..8e8e1f4d0 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -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); diff --git a/vite.config.ts b/vite.config.ts index f453d997d..eceecc6fe 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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) {