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",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capabilities for World Monitor main and settings windows",
|
"description": "Capabilities for World Monitor main and settings windows",
|
||||||
"windows": ["main", "settings"],
|
"windows": ["main", "settings", "live-channels"],
|
||||||
"permissions": ["core:default"]
|
"permissions": ["core:default"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -478,6 +478,24 @@ fn close_settings_window(app: AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
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).
|
/// 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.
|
/// Called from frontend when browser CORS and sidecar Node.js TLS both fail.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -533,6 +551,41 @@ fn open_settings_window(app: &AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
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>> {
|
fn build_app_menu(handle: &AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
|
||||||
let settings_item = MenuItem::with_id(
|
let settings_item = MenuItem::with_id(
|
||||||
handle,
|
handle,
|
||||||
@@ -986,6 +1039,8 @@ fn main() {
|
|||||||
open_sidecar_log_file,
|
open_sidecar_log_file,
|
||||||
open_settings_window_command,
|
open_settings_window_command,
|
||||||
close_settings_window,
|
close_settings_window,
|
||||||
|
open_live_channels_window_command,
|
||||||
|
close_live_channels_window,
|
||||||
open_url,
|
open_url,
|
||||||
fetch_polymarket
|
fetch_polymarket
|
||||||
])
|
])
|
||||||
|
|||||||
20
src/App.ts
20
src/App.ts
@@ -2645,6 +2645,26 @@ export class App {
|
|||||||
document.getElementById('settingsModal')?.classList.add('active');
|
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('modalClose')?.addEventListener('click', () => {
|
||||||
document.getElementById('settingsModal')?.classList.remove('active');
|
document.getElementById('settingsModal')?.classList.remove('active');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Panel } from './Panel';
|
import { Panel } from './Panel';
|
||||||
import { fetchLiveVideoId } from '@/services/live-news';
|
import { fetchLiveVideoId } from '@/services/live-news';
|
||||||
import { isDesktopRuntime, getRemoteApiBaseUrl } from '@/services/runtime';
|
import { isDesktopRuntime, getRemoteApiBaseUrl } from '@/services/runtime';
|
||||||
|
import { invokeTauri } from '@/services/tauri-bridge';
|
||||||
import { t } from '../services/i18n';
|
import { t } from '../services/i18n';
|
||||||
|
import { loadFromStorage, saveToStorage } from '@/utils';
|
||||||
|
import { STORAGE_KEYS } from '@/config';
|
||||||
|
|
||||||
// YouTube IFrame Player API types
|
// YouTube IFrame Player API types
|
||||||
type YouTubePlayer = {
|
type YouTubePlayer = {
|
||||||
@@ -39,7 +42,7 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LiveChannel {
|
export interface LiveChannel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
handle: string; // YouTube channel handle (e.g., @bloomberg)
|
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 },
|
{ 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 {
|
export class LiveNewsPanel extends Panel {
|
||||||
private static apiPromise: Promise<void> | null = null;
|
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 channelSwitcher: HTMLElement | null = null;
|
||||||
private isMuted = true;
|
private isMuted = true;
|
||||||
private isPlaying = true;
|
private isPlaying = true;
|
||||||
@@ -109,6 +170,9 @@ export class LiveNewsPanel extends Panel {
|
|||||||
this.youtubeOrigin = LiveNewsPanel.resolveYouTubeOrigin();
|
this.youtubeOrigin = LiveNewsPanel.resolveYouTubeOrigin();
|
||||||
this.playerElementId = `live-news-player-${Date.now()}`;
|
this.playerElementId = `live-news-player-${Date.now()}`;
|
||||||
this.element.classList.add('panel-wide');
|
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.createLiveButton();
|
||||||
this.createMuteButton();
|
this.createMuteButton();
|
||||||
this.createChannelSwitcher();
|
this.createChannelSwitcher();
|
||||||
@@ -117,6 +181,10 @@ export class LiveNewsPanel extends Panel {
|
|||||||
this.setupIdleDetection();
|
this.setupIdleDetection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private saveChannels(): void {
|
||||||
|
saveChannelsToStorage(this.channels);
|
||||||
|
}
|
||||||
|
|
||||||
private get embedOrigin(): string {
|
private get embedOrigin(): string {
|
||||||
try { return new URL(getRemoteApiBaseUrl()).origin; } catch { return 'https://worldmonitor.app'; }
|
try { return new URL(getRemoteApiBaseUrl()).origin; } catch { return 'https://worldmonitor.app'; }
|
||||||
}
|
}
|
||||||
@@ -302,20 +370,100 @@ export class LiveNewsPanel extends Panel {
|
|||||||
this.syncPlayerState();
|
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 {
|
private createChannelSwitcher(): void {
|
||||||
this.channelSwitcher = document.createElement('div');
|
this.channelSwitcher = document.createElement('div');
|
||||||
this.channelSwitcher.className = 'live-news-switcher';
|
this.channelSwitcher.className = 'live-news-switcher';
|
||||||
|
|
||||||
LIVE_CHANNELS.forEach(channel => {
|
for (const channel of this.channels) {
|
||||||
const btn = document.createElement('button');
|
this.channelSwitcher.appendChild(this.createChannelButton(channel));
|
||||||
btn.className = `live-channel-btn ${channel.id === this.activeChannel.id ? 'active' : ''}`;
|
}
|
||||||
btn.dataset.channelId = channel.id;
|
|
||||||
btn.textContent = channel.name;
|
this.channelSwitcher.addEventListener('dragover', (e) => {
|
||||||
btn.addEventListener('click', () => this.switchChannel(channel));
|
e.preventDefault();
|
||||||
this.channelSwitcher!.appendChild(btn);
|
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> {
|
private async resolveChannelVideo(channel: LiveChannel, forceFallback = false): Promise<void> {
|
||||||
@@ -682,6 +830,17 @@ export class LiveNewsPanel extends Panel {
|
|||||||
this.syncPlayerState();
|
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 {
|
public destroy(): void {
|
||||||
if (this.idleTimeout) {
|
if (this.idleTimeout) {
|
||||||
clearTimeout(this.idleTimeout);
|
clearTimeout(this.idleTimeout);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const STORAGE_KEYS = {
|
|||||||
monitors: 'worldmonitor-monitors',
|
monitors: 'worldmonitor-monitors',
|
||||||
mapLayers: 'worldmonitor-layers',
|
mapLayers: 'worldmonitor-layers',
|
||||||
disabledFeeds: 'worldmonitor-disabled-feeds',
|
disabledFeeds: 'worldmonitor-disabled-feeds',
|
||||||
|
liveChannels: 'worldmonitor-live-channels',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Type definitions for variant configs
|
// 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",
|
"retry": "Retry",
|
||||||
"notLive": "{{name}} is not currently live",
|
"notLive": "{{name}} is not currently live",
|
||||||
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
|
"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": {
|
"popups": {
|
||||||
@@ -1750,7 +1762,7 @@
|
|||||||
"noDataAvailable": "لا تتوفر بيانات",
|
"noDataAvailable": "لا تتوفر بيانات",
|
||||||
"updated": "تم التحديث للتو",
|
"updated": "تم التحديث للتو",
|
||||||
"ago": "منذ {{time}}",
|
"ago": "منذ {{time}}",
|
||||||
"retrying": "جارٍ إعادة المحاولة…",
|
"retrying": "جاري إعادة المحاولة...",
|
||||||
"failedToLoad": "فشل تحميل البيانات",
|
"failedToLoad": "فشل تحميل البيانات",
|
||||||
"noDataShort": "لا بيانات",
|
"noDataShort": "لا بيانات",
|
||||||
"upstreamUnavailable": "واجهة API المصدر غير متاحة — ستتم إعادة المحاولة تلقائياً",
|
"upstreamUnavailable": "واجهة API المصدر غير متاحة — ستتم إعادة المحاولة تلقائياً",
|
||||||
@@ -1797,7 +1809,6 @@
|
|||||||
"close": "إغلاق",
|
"close": "إغلاق",
|
||||||
"currentVariant": "(الحالي)",
|
"currentVariant": "(الحالي)",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "جاري إعادة المحاولة...",
|
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1160,7 +1160,19 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"notLive": "{{name}} is not currently live",
|
"notLive": "{{name}} is not currently live",
|
||||||
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
|
"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": {
|
"popups": {
|
||||||
@@ -1749,7 +1761,7 @@
|
|||||||
"noData": "Keine Daten verfügbar",
|
"noData": "Keine Daten verfügbar",
|
||||||
"updated": "Gerade aktualisiert",
|
"updated": "Gerade aktualisiert",
|
||||||
"ago": "vor {{time}}",
|
"ago": "vor {{time}}",
|
||||||
"retrying": "Erneuter Versuch…",
|
"retrying": "Wird wiederholt...",
|
||||||
"failedToLoad": "Fehler beim Laden der Daten",
|
"failedToLoad": "Fehler beim Laden der Daten",
|
||||||
"noDataShort": "Keine Daten",
|
"noDataShort": "Keine Daten",
|
||||||
"noDataAvailable": "Keine Daten verfügbar",
|
"noDataAvailable": "Keine Daten verfügbar",
|
||||||
@@ -1797,7 +1809,6 @@
|
|||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"currentVariant": "(aktuell)",
|
"currentVariant": "(aktuell)",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "Wird wiederholt...",
|
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,8 @@
|
|||||||
"filterSources": "Filter sources...",
|
"filterSources": "Filter sources...",
|
||||||
"sourcesEnabled": "{{enabled}}/{{total}} enabled",
|
"sourcesEnabled": "{{enabled}}/{{total}} enabled",
|
||||||
"finance": "FINANCE",
|
"finance": "FINANCE",
|
||||||
"toggleTheme": "Toggle dark/light mode"
|
"toggleTheme": "Toggle dark/light mode",
|
||||||
|
"panelDisplayCaption": "Choose which panels to show on the dashboard"
|
||||||
},
|
},
|
||||||
"panels": {
|
"panels": {
|
||||||
"liveNews": "Live News",
|
"liveNews": "Live News",
|
||||||
@@ -1177,7 +1178,19 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"notLive": "{{name}} is not currently live",
|
"notLive": "{{name}} is not currently live",
|
||||||
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
|
"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": {
|
"popups": {
|
||||||
|
|||||||
@@ -1160,7 +1160,19 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"notLive": "{{name}} is not currently live",
|
"notLive": "{{name}} is not currently live",
|
||||||
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
|
"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": {
|
"popups": {
|
||||||
@@ -1749,7 +1761,7 @@
|
|||||||
"noData": "No hay datos disponibles",
|
"noData": "No hay datos disponibles",
|
||||||
"updated": "Actualizado ahora",
|
"updated": "Actualizado ahora",
|
||||||
"ago": "hace {{time}}",
|
"ago": "hace {{time}}",
|
||||||
"retrying": "Reintentando…",
|
"retrying": "Reintentando...",
|
||||||
"failedToLoad": "Error al cargar los datos",
|
"failedToLoad": "Error al cargar los datos",
|
||||||
"noDataShort": "Sin datos",
|
"noDataShort": "Sin datos",
|
||||||
"noDataAvailable": "No hay datos disponibles",
|
"noDataAvailable": "No hay datos disponibles",
|
||||||
@@ -1797,7 +1809,6 @@
|
|||||||
"close": "Cerrar",
|
"close": "Cerrar",
|
||||||
"currentVariant": "(actual)",
|
"currentVariant": "(actual)",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "Reintentando...",
|
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1160,7 +1160,19 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"notLive": "{{name}} is not currently live",
|
"notLive": "{{name}} is not currently live",
|
||||||
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
|
"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": {
|
"popups": {
|
||||||
@@ -1749,7 +1761,7 @@
|
|||||||
"noData": "Aucune donnée disponible",
|
"noData": "Aucune donnée disponible",
|
||||||
"updated": "Mis à jour à l'instant",
|
"updated": "Mis à jour à l'instant",
|
||||||
"ago": "il y a {{time}}",
|
"ago": "il y a {{time}}",
|
||||||
"retrying": "Nouvelle tentative…",
|
"retrying": "Nouvelle tentative...",
|
||||||
"failedToLoad": "Échec du chargement des données",
|
"failedToLoad": "Échec du chargement des données",
|
||||||
"noDataShort": "Aucune donnée",
|
"noDataShort": "Aucune donnée",
|
||||||
"upstreamUnavailable": "API source indisponible — nouvelle tentative automatique",
|
"upstreamUnavailable": "API source indisponible — nouvelle tentative automatique",
|
||||||
@@ -1797,7 +1809,6 @@
|
|||||||
"close": "Fermer",
|
"close": "Fermer",
|
||||||
"currentVariant": "(actuel)",
|
"currentVariant": "(actuel)",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "Nouvelle tentative...",
|
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1160,7 +1160,19 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"notLive": "{{name}} is not currently live",
|
"notLive": "{{name}} is not currently live",
|
||||||
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
|
"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": {
|
"popups": {
|
||||||
@@ -1749,7 +1761,7 @@
|
|||||||
"noData": "Nessun dato disponibile",
|
"noData": "Nessun dato disponibile",
|
||||||
"updated": "Aggiornato ora",
|
"updated": "Aggiornato ora",
|
||||||
"ago": "{{time}} fa",
|
"ago": "{{time}} fa",
|
||||||
"retrying": "Nuovo tentativo…",
|
"retrying": "Nuovo tentativo...",
|
||||||
"failedToLoad": "Errore nel caricamento dei dati",
|
"failedToLoad": "Errore nel caricamento dei dati",
|
||||||
"noDataShort": "Nessun dato",
|
"noDataShort": "Nessun dato",
|
||||||
"noDataAvailable": "Nessun dato disponibile",
|
"noDataAvailable": "Nessun dato disponibile",
|
||||||
@@ -1797,7 +1809,6 @@
|
|||||||
"close": "Chiudi",
|
"close": "Chiudi",
|
||||||
"currentVariant": "(corrente)",
|
"currentVariant": "(corrente)",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "Nuovo tentativo...",
|
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,8 @@
|
|||||||
"filterSources": "ソースを絞り込み...",
|
"filterSources": "ソースを絞り込み...",
|
||||||
"sourcesEnabled": "{{enabled}}/{{total}} 有効",
|
"sourcesEnabled": "{{enabled}}/{{total}} 有効",
|
||||||
"finance": "金融",
|
"finance": "金融",
|
||||||
"toggleTheme": "ダーク/ライトモード切替"
|
"toggleTheme": "ダーク/ライトモード切替",
|
||||||
|
"panelDisplayCaption": "ダッシュボードに表示するパネルを選択"
|
||||||
},
|
},
|
||||||
"panels": {
|
"panels": {
|
||||||
"liveNews": "ライブニュース",
|
"liveNews": "ライブニュース",
|
||||||
@@ -1160,7 +1161,19 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"notLive": "{{name}} is not currently live",
|
"notLive": "{{name}} is not currently live",
|
||||||
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
|
"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": {
|
"popups": {
|
||||||
@@ -1750,7 +1763,7 @@
|
|||||||
"noDataAvailable": "データなし",
|
"noDataAvailable": "データなし",
|
||||||
"updated": "たった今更新",
|
"updated": "たった今更新",
|
||||||
"ago": "{{time}}前",
|
"ago": "{{time}}前",
|
||||||
"retrying": "再試行中…",
|
"retrying": "再試行中...",
|
||||||
"failedToLoad": "データの読み込みに失敗",
|
"failedToLoad": "データの読み込みに失敗",
|
||||||
"noDataShort": "データなし",
|
"noDataShort": "データなし",
|
||||||
"upstreamUnavailable": "上流APIが利用不可 — 自動リトライ予定",
|
"upstreamUnavailable": "上流APIが利用不可 — 自動リトライ予定",
|
||||||
@@ -1797,7 +1810,6 @@
|
|||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
"currentVariant": "(現在)",
|
"currentVariant": "(現在)",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "再試行中...",
|
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1068,7 +1068,19 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"notLive": "{{name}} is not currently live",
|
"notLive": "{{name}} is not currently live",
|
||||||
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
|
"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": {
|
"popups": {
|
||||||
@@ -1655,7 +1667,7 @@
|
|||||||
"loading": "Laden...",
|
"loading": "Laden...",
|
||||||
"error": "Er is een fout opgetreden",
|
"error": "Er is een fout opgetreden",
|
||||||
"updated": "Bijgewerkt: {{time}}",
|
"updated": "Bijgewerkt: {{time}}",
|
||||||
"retrying": "Opnieuw proberen…",
|
"retrying": "Opnieuw proberen...",
|
||||||
"failedToLoad": "Laden van gegevens mislukt",
|
"failedToLoad": "Laden van gegevens mislukt",
|
||||||
"noDataShort": "Geen gegevens",
|
"noDataShort": "Geen gegevens",
|
||||||
"noDataAvailable": "Geen gegevens beschikbaar",
|
"noDataAvailable": "Geen gegevens beschikbaar",
|
||||||
@@ -1705,7 +1717,6 @@
|
|||||||
"close": "Sluiten",
|
"close": "Sluiten",
|
||||||
"currentVariant": "(huidig)",
|
"currentVariant": "(huidig)",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "Opnieuw proberen...",
|
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
|
|||||||
@@ -1160,7 +1160,19 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"notLive": "{{name}} is not currently live",
|
"notLive": "{{name}} is not currently live",
|
||||||
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
|
"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": {
|
"popups": {
|
||||||
@@ -1749,7 +1761,7 @@
|
|||||||
"noData": "Brak danych",
|
"noData": "Brak danych",
|
||||||
"updated": "Zaktualizowano przed chwilą",
|
"updated": "Zaktualizowano przed chwilą",
|
||||||
"ago": "{{time}} temu",
|
"ago": "{{time}} temu",
|
||||||
"retrying": "Ponowna próba…",
|
"retrying": "Ponawiam próbę...",
|
||||||
"failedToLoad": "Nie udało się załadować danych",
|
"failedToLoad": "Nie udało się załadować danych",
|
||||||
"noDataShort": "Brak danych",
|
"noDataShort": "Brak danych",
|
||||||
"noDataAvailable": "Brak dostępnych danych",
|
"noDataAvailable": "Brak dostępnych danych",
|
||||||
@@ -1797,7 +1809,6 @@
|
|||||||
"close": "Zamknij",
|
"close": "Zamknij",
|
||||||
"currentVariant": "(bieżący)",
|
"currentVariant": "(bieżący)",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "Ponawiam próbę...",
|
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1068,7 +1068,19 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"notLive": "{{name}} is not currently live",
|
"notLive": "{{name}} is not currently live",
|
||||||
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
|
"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": {
|
"popups": {
|
||||||
@@ -1655,7 +1667,7 @@
|
|||||||
"loading": "Carregando...",
|
"loading": "Carregando...",
|
||||||
"error": "Ocorreu um erro",
|
"error": "Ocorreu um erro",
|
||||||
"updated": "Atualizado: {{time}}",
|
"updated": "Atualizado: {{time}}",
|
||||||
"retrying": "Tentando novamente…",
|
"retrying": "Tentando novamente...",
|
||||||
"failedToLoad": "Falha ao carregar os dados",
|
"failedToLoad": "Falha ao carregar os dados",
|
||||||
"noDataShort": "Sem dados",
|
"noDataShort": "Sem dados",
|
||||||
"noDataAvailable": "Nenhum dado disponível",
|
"noDataAvailable": "Nenhum dado disponível",
|
||||||
@@ -1705,7 +1717,6 @@
|
|||||||
"close": "Fechar",
|
"close": "Fechar",
|
||||||
"currentVariant": "(atual)",
|
"currentVariant": "(atual)",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "Tentando novamente...",
|
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
|
|||||||
@@ -1160,7 +1160,19 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"notLive": "{{name}} is not currently live",
|
"notLive": "{{name}} is not currently live",
|
||||||
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
|
"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": {
|
"popups": {
|
||||||
@@ -1750,7 +1762,7 @@
|
|||||||
"noDataAvailable": "Данные недоступны",
|
"noDataAvailable": "Данные недоступны",
|
||||||
"updated": "Обновлено только что",
|
"updated": "Обновлено только что",
|
||||||
"ago": "{{time}} назад",
|
"ago": "{{time}} назад",
|
||||||
"retrying": "Повторная попытка…",
|
"retrying": "Повторная попытка...",
|
||||||
"failedToLoad": "Не удалось загрузить данные",
|
"failedToLoad": "Не удалось загрузить данные",
|
||||||
"noDataShort": "Нет данных",
|
"noDataShort": "Нет данных",
|
||||||
"upstreamUnavailable": "Внешний API недоступен — автоматическая повторная попытка",
|
"upstreamUnavailable": "Внешний API недоступен — автоматическая повторная попытка",
|
||||||
@@ -1797,7 +1809,6 @@
|
|||||||
"close": "Закрыть",
|
"close": "Закрыть",
|
||||||
"currentVariant": "(текущий)",
|
"currentVariant": "(текущий)",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "Повторная попытка...",
|
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1068,7 +1068,19 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"notLive": "{{name}} is not currently live",
|
"notLive": "{{name}} is not currently live",
|
||||||
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
|
"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": {
|
"popups": {
|
||||||
@@ -1655,7 +1667,7 @@
|
|||||||
"loading": "Laddar...",
|
"loading": "Laddar...",
|
||||||
"error": "Ett fel inträffade",
|
"error": "Ett fel inträffade",
|
||||||
"updated": "Uppdaterad: {{time}}",
|
"updated": "Uppdaterad: {{time}}",
|
||||||
"retrying": "Försöker igen…",
|
"retrying": "Försöker igen...",
|
||||||
"failedToLoad": "Kunde inte ladda data",
|
"failedToLoad": "Kunde inte ladda data",
|
||||||
"noDataShort": "Inga data",
|
"noDataShort": "Inga data",
|
||||||
"noDataAvailable": "Inga data tillgängliga",
|
"noDataAvailable": "Inga data tillgängliga",
|
||||||
@@ -1705,7 +1717,6 @@
|
|||||||
"close": "Stäng",
|
"close": "Stäng",
|
||||||
"currentVariant": "(aktuell)",
|
"currentVariant": "(aktuell)",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "Försöker igen...",
|
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
|
|||||||
@@ -1137,7 +1137,19 @@
|
|||||||
"retry": "ลองใหม่",
|
"retry": "ลองใหม่",
|
||||||
"notLive": "{{name}} ไม่ได้ถ่ายทอดสดอยู่ในขณะนี้",
|
"notLive": "{{name}} ไม่ได้ถ่ายทอดสดอยู่ในขณะนี้",
|
||||||
"cannotEmbed": "ไม่สามารถฝัง {{name}} ในแอปนี้ได้ (YouTube {{code}})",
|
"cannotEmbed": "ไม่สามารถฝัง {{name}} ในแอปนี้ได้ (YouTube {{code}})",
|
||||||
"openOnYouTube": "เปิดบน YouTube"
|
"openOnYouTube": "เปิดบน YouTube",
|
||||||
|
"manage": "จัดการช่อง",
|
||||||
|
"addChannel": "เพิ่มช่อง",
|
||||||
|
"remove": "ลบ",
|
||||||
|
"youtubeHandle": "YouTube handle (เช่น @Channel)",
|
||||||
|
"displayName": "ชื่อที่แสดง (ไม่บังคับ)",
|
||||||
|
"openPanelSettings": "การตั้งค่าการแสดงผลแผง",
|
||||||
|
"channelSettings": "การตั้งค่าช่อง",
|
||||||
|
"save": "บันทึก",
|
||||||
|
"cancel": "ยกเลิก",
|
||||||
|
"confirmDelete": "ลบช่องนี้หรือไม่?",
|
||||||
|
"confirmTitle": "ยืนยัน",
|
||||||
|
"restoreDefaults": "คืนค่าช่องเริ่มต้น"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"popups": {
|
"popups": {
|
||||||
@@ -1727,7 +1739,7 @@
|
|||||||
"noDataAvailable": "ไม่มีข้อมูล",
|
"noDataAvailable": "ไม่มีข้อมูล",
|
||||||
"updated": "อัปเดตเมื่อสักครู่",
|
"updated": "อัปเดตเมื่อสักครู่",
|
||||||
"ago": "{{time}} ที่แล้ว",
|
"ago": "{{time}} ที่แล้ว",
|
||||||
"retrying": "กำลังลองใหม่…",
|
"retrying": "กำลังลองใหม่...",
|
||||||
"failedToLoad": "โหลดข้อมูลไม่สำเร็จ",
|
"failedToLoad": "โหลดข้อมูลไม่สำเร็จ",
|
||||||
"noDataShort": "ไม่มีข้อมูล",
|
"noDataShort": "ไม่มีข้อมูล",
|
||||||
"upstreamUnavailable": "API ต้นทางไม่พร้อมใช้งาน — จะลองใหม่อัตโนมัติ",
|
"upstreamUnavailable": "API ต้นทางไม่พร้อมใช้งาน — จะลองใหม่อัตโนมัติ",
|
||||||
@@ -1774,7 +1786,6 @@
|
|||||||
"close": "ปิด",
|
"close": "ปิด",
|
||||||
"currentVariant": "(ปัจจุบัน)",
|
"currentVariant": "(ปัจจุบัน)",
|
||||||
"retry": "ลองใหม่",
|
"retry": "ลองใหม่",
|
||||||
"retrying": "กำลังลองใหม่...",
|
|
||||||
"refresh": "รีเฟรช"
|
"refresh": "รีเฟรช"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1160,7 +1160,19 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"notLive": "{{name}} is not currently live",
|
"notLive": "{{name}} is not currently live",
|
||||||
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
|
"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": {
|
"popups": {
|
||||||
@@ -1750,7 +1762,7 @@
|
|||||||
"noDataAvailable": "Veri mevcut degil",
|
"noDataAvailable": "Veri mevcut degil",
|
||||||
"updated": "Az once guncellendi",
|
"updated": "Az once guncellendi",
|
||||||
"ago": "{{time}} once",
|
"ago": "{{time}} once",
|
||||||
"retrying": "Yeniden deneniyor…",
|
"retrying": "Tekrar deneniyor...",
|
||||||
"failedToLoad": "Veri yuklenemedi",
|
"failedToLoad": "Veri yuklenemedi",
|
||||||
"noDataShort": "Veri yok",
|
"noDataShort": "Veri yok",
|
||||||
"upstreamUnavailable": "Ust kaynak API'si kullanilamiyor — otomatik yeniden denenecek",
|
"upstreamUnavailable": "Ust kaynak API'si kullanilamiyor — otomatik yeniden denenecek",
|
||||||
@@ -1797,7 +1809,6 @@
|
|||||||
"close": "Kapat",
|
"close": "Kapat",
|
||||||
"currentVariant": "(mevcut)",
|
"currentVariant": "(mevcut)",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "Tekrar deneniyor...",
|
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1137,7 +1137,19 @@
|
|||||||
"retry": "Thử lại",
|
"retry": "Thử lại",
|
||||||
"notLive": "{{name}} hiện không phát trực tiếp",
|
"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}})",
|
"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": {
|
"popups": {
|
||||||
@@ -1727,7 +1739,7 @@
|
|||||||
"noDataAvailable": "Không có dữ liệu",
|
"noDataAvailable": "Không có dữ liệu",
|
||||||
"updated": "Vừa cập nhật",
|
"updated": "Vừa cập nhật",
|
||||||
"ago": "{{time}} trước",
|
"ago": "{{time}} trước",
|
||||||
"retrying": "Đang thử lại…",
|
"retrying": "Đang thử lại...",
|
||||||
"failedToLoad": "Không thể tải dữ liệu",
|
"failedToLoad": "Không thể tải dữ liệu",
|
||||||
"noDataShort": "Không có 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",
|
"upstreamUnavailable": "API nguồn không khả dụng — sẽ tự động thử lại",
|
||||||
@@ -1774,7 +1786,6 @@
|
|||||||
"close": "Đóng",
|
"close": "Đóng",
|
||||||
"currentVariant": "(hiện tại)",
|
"currentVariant": "(hiện tại)",
|
||||||
"retry": "Thử lại",
|
"retry": "Thử lại",
|
||||||
"retrying": "Đang thử lại...",
|
|
||||||
"refresh": "Làm mới"
|
"refresh": "Làm mới"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1160,7 +1160,19 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"notLive": "{{name}} is not currently live",
|
"notLive": "{{name}} is not currently live",
|
||||||
"cannotEmbed": "{{name}} cannot be embedded in this app (YouTube {{code}})",
|
"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": {
|
"popups": {
|
||||||
@@ -1750,7 +1762,7 @@
|
|||||||
"noDataAvailable": "无可用数据",
|
"noDataAvailable": "无可用数据",
|
||||||
"updated": "刚刚更新",
|
"updated": "刚刚更新",
|
||||||
"ago": "{{time}}前",
|
"ago": "{{time}}前",
|
||||||
"retrying": "正在重试…",
|
"retrying": "正在重试...",
|
||||||
"failedToLoad": "加载数据失败",
|
"failedToLoad": "加载数据失败",
|
||||||
"noDataShort": "无数据",
|
"noDataShort": "无数据",
|
||||||
"upstreamUnavailable": "上游API不可用 — 将自动重试",
|
"upstreamUnavailable": "上游API不可用 — 将自动重试",
|
||||||
@@ -1797,7 +1809,6 @@
|
|||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
"currentVariant": "(当前)",
|
"currentVariant": "(当前)",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "正在重试...",
|
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/main.ts
34
src/main.ts
@@ -148,14 +148,32 @@ requestAnimationFrame(() => {
|
|||||||
// Clear stale settings-open flag (survives ungraceful shutdown)
|
// Clear stale settings-open flag (survives ungraceful shutdown)
|
||||||
localStorage.removeItem('wm-settings-open');
|
localStorage.removeItem('wm-settings-open');
|
||||||
|
|
||||||
const app = new App('app');
|
// Standalone windows: ?settings=1 = panel display settings, ?live-channels=1 = channel management
|
||||||
app
|
// Both need i18n initialized so t() does not return undefined.
|
||||||
.init()
|
const urlParams = new URL(location.href).searchParams;
|
||||||
.then(() => {
|
if (urlParams.get('settings') === '1') {
|
||||||
// Clear the one-shot guard after a successful boot so future stale-chunk incidents can recover.
|
void Promise.all([import('./services/i18n'), import('./settings-window')]).then(
|
||||||
clearChunkReloadGuard(chunkReloadStorageKey);
|
async ([i18n, m]) => {
|
||||||
})
|
await i18n.initI18n();
|
||||||
.catch(console.error);
|
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)
|
// Debug helpers for geo-convergence testing (remove in production)
|
||||||
(window as unknown as Record<string, unknown>).geoDebug = {
|
(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 Panel */
|
||||||
.live-news-switcher {
|
.live-news-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
background: var(--darken-heavy);
|
background: var(--darken-heavy);
|
||||||
@@ -1196,6 +1198,21 @@ canvas,
|
|||||||
flex-shrink: 0;
|
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 {
|
.live-channel-btn {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -1236,6 +1253,277 @@ canvas,
|
|||||||
border-style: dashed;
|
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 {
|
.live-offline {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -4586,6 +4874,59 @@ a.prediction-link:hover {
|
|||||||
cursor: pointer;
|
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 {
|
.panel-toggle-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
|||||||
@@ -632,6 +632,7 @@ export default defineConfig({
|
|||||||
input: {
|
input: {
|
||||||
main: resolve(__dirname, 'index.html'),
|
main: resolve(__dirname, 'index.html'),
|
||||||
settings: resolve(__dirname, 'settings.html'),
|
settings: resolve(__dirname, 'settings.html'),
|
||||||
|
liveChannels: resolve(__dirname, 'live-channels.html'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
manualChunks(id) {
|
manualChunks(id) {
|
||||||
|
|||||||
Reference in New Issue
Block a user