mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
14
live-channels.html
Normal file
14
live-channels.html
Normal 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>
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
])
|
||||
|
||||
20
src/App.ts
20
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<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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
14
src/live-channels-main.ts
Normal 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
334
src/live-channels-window.ts
Normal 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 = '';
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "รีเฟรช"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
34
src/main.ts
34
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<string, unknown>).geoDebug = {
|
||||
|
||||
116
src/settings-window.ts
Normal file
116
src/settings-window.ts
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user