diff --git a/src/db/repositories/system-config-repository.ts b/src/db/repositories/system-config-repository.ts index d500249..129c47e 100644 --- a/src/db/repositories/system-config-repository.ts +++ b/src/db/repositories/system-config-repository.ts @@ -12,6 +12,8 @@ export const APP_CHECK_INTERVAL = 'app.check_interval'; export const APP_CONCURRENT_DOWNLOADS = 'app.concurrent_downloads'; export const APP_OUTPUT_TEMPLATE = 'app.output_template'; export const APP_NFO_ENABLED = 'app.nfo_enabled'; +export const APP_TIMEZONE = 'app.timezone'; +export const APP_THEME = 'app.theme'; export const YTDLP_LAST_UPDATED = 'ytdlp.last_updated'; // ── Read / Write ── diff --git a/src/frontend/index.html b/src/frontend/index.html index 492c4be..707f4d0 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -7,6 +7,13 @@ +
diff --git a/src/frontend/src/pages/Settings.tsx b/src/frontend/src/pages/Settings.tsx index d9c34fc..64c4105 100644 --- a/src/frontend/src/pages/Settings.tsx +++ b/src/frontend/src/pages/Settings.tsx @@ -1,5 +1,5 @@ -import { useState, useCallback, useMemo, useEffect } from 'react'; -import { Plus, Pencil, Trash2, Loader, RefreshCw, Star, Bell, Send, CheckCircle, XCircle, Eye, EyeOff, Copy, RotateCw, Key, Globe, Save, FolderTree, Server } from 'lucide-react'; +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { Plus, Pencil, Trash2, Loader, RefreshCw, Star, Bell, Send, CheckCircle, XCircle, Eye, EyeOff, Copy, RotateCw, Key, Globe, Save, FolderTree, Server, Sun, Moon, Clock } from 'lucide-react'; import { useFormatProfiles, useCreateFormatProfile, @@ -106,8 +106,28 @@ export function SettingsPage() { const [concurrentDownloads, setConcurrentDownloads] = useState(''); const [outputTemplate, setOutputTemplate] = useState(''); const [nfoEnabled, setNfoEnabled] = useState(false); + const [timezone, setTimezone] = useState('UTC'); + const [theme, setTheme] = useState<'dark' | 'light'>('dark'); const [settingsSaveFlash, setSettingsSaveFlash] = useState(false); + // Timezone options — computed once + const timezoneOptions = useMemo(() => { + try { + return Intl.supportedValuesOf('timeZone'); + } catch { + // Fallback for older browsers + return ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo', 'Asia/Shanghai', 'Australia/Sydney']; + } + }, []); + + // Timezone search filter + const [tzSearch, setTzSearch] = useState(''); + const filteredTimezones = useMemo(() => { + if (!tzSearch) return timezoneOptions; + const lower = tzSearch.toLowerCase(); + return timezoneOptions.filter(tz => tz.toLowerCase().includes(lower)); + }, [timezoneOptions, tzSearch]); + // Initialize local state from fetched app settings useEffect(() => { if (appSettings) { @@ -115,9 +135,17 @@ export function SettingsPage() { setConcurrentDownloads(appSettings.concurrentDownloads); setOutputTemplate(appSettings.outputTemplate); setNfoEnabled(appSettings.nfoEnabled); + setTimezone(appSettings.timezone); + setTheme(appSettings.theme); } }, [appSettings]); + // Apply theme to document element + localStorage for instant apply on load + useEffect(() => { + document.documentElement.dataset.theme = theme; + localStorage.setItem('tubearr-theme', theme); + }, [theme]); + const settingsDirty = checkInterval !== '' && concurrentDownloads !== '' && @@ -125,7 +153,9 @@ export function SettingsPage() { (Number(checkInterval) !== appSettings.checkInterval || Number(concurrentDownloads) !== appSettings.concurrentDownloads || outputTemplate !== appSettings.outputTemplate || - nfoEnabled !== appSettings.nfoEnabled); + nfoEnabled !== appSettings.nfoEnabled || + timezone !== appSettings.timezone || + theme !== appSettings.theme); const settingsValid = checkInterval !== '' && @@ -177,6 +207,8 @@ export function SettingsPage() { concurrentDownloads: Number(concurrentDownloads), outputTemplate, nfoEnabled, + timezone, + theme, }, { onSuccess: () => { @@ -1033,6 +1065,116 @@ export function SettingsPage() { )} + {/* Timezone selector */} + + + + + Timezone + + + + {appSettingsLoading ? ( + + + Loading… + + ) : ( +
+ setTzSearch(e.target.value)} + placeholder="Filter timezones…" + aria-label="Filter timezone list" + style={{ + width: '100%', + padding: 'var(--space-2) var(--space-3)', + borderRadius: 'var(--radius-md)', + border: '1px solid var(--border)', + backgroundColor: 'var(--bg-main)', + color: 'var(--text-primary)', + fontSize: 'var(--font-size-sm)', + }} + /> + +
+ )} + + + {/* Theme toggle */} + + + Theme + + +
+ + +
+ + diff --git a/src/frontend/src/styles/theme.css b/src/frontend/src/styles/theme.css index a7eb58e..ce232f1 100644 --- a/src/frontend/src/styles/theme.css +++ b/src/frontend/src/styles/theme.css @@ -97,3 +97,57 @@ --glass-bg: rgba(20, 22, 30, 0.6); --glass-border: rgba(255, 255, 255, 0.08); } + +/* ── Light Theme ── */ +[data-theme="light"] { + /* ── Backgrounds ── */ + --bg-main: #f5f5f8; + --bg-sidebar: #ebedf2; + --bg-card: rgba(255, 255, 255, 0.9); + --bg-card-solid: #ffffff; + --bg-input: #ffffff; + --bg-hover: rgba(0, 0, 0, 0.04); + --bg-selected: rgba(0, 0, 0, 0.06); + --bg-header: #f0f1f5; + --bg-toolbar: #f0f1f5; + --bg-modal-overlay: rgba(0, 0, 0, 0.3); + --bg-glass: rgba(255, 255, 255, 0.7); + + /* ── Accent ── */ + --accent: #d14836; + --accent-hover: #c03c2b; + --accent-subtle: rgba(209, 72, 54, 0.08); + --accent-glow: rgba(209, 72, 54, 0.15); + + /* ── Text ── */ + --text-primary: #1a1c24; + --text-secondary: #5a5d6b; + --text-muted: #9397a5; + --text-inverse: #f5f5f8; + --text-link: #d14836; + + /* ── Status colors ── */ + --success: #1e8e3e; + --success-bg: rgba(30, 142, 62, 0.08); + --warning: #e37400; + --warning-bg: rgba(227, 116, 0, 0.08); + --danger: #d93025; + --danger-bg: rgba(217, 48, 37, 0.08); + --info: #d14836; + --info-bg: rgba(209, 72, 54, 0.08); + + /* ── Borders ── */ + --border: rgba(0, 0, 0, 0.08); + --border-light: rgba(0, 0, 0, 0.12); + --border-accent: rgba(209, 72, 54, 0.25); + + /* ── Shadows ── */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12); + --shadow-glow: 0 0 20px rgba(209, 72, 54, 0.1); + + /* ── Glassmorphism ── */ + --glass-bg: rgba(255, 255, 255, 0.7); + --glass-border: rgba(0, 0, 0, 0.06); +} diff --git a/src/server/routes/system.ts b/src/server/routes/system.ts index c6e07b0..02db44a 100644 --- a/src/server/routes/system.ts +++ b/src/server/routes/system.ts @@ -18,6 +18,8 @@ import { APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE, APP_NFO_ENABLED, + APP_TIMEZONE, + APP_THEME, YTDLP_LAST_UPDATED, } from '../../db/repositories/system-config-repository'; import { getYtDlpVersion, updateYtDlp } from '../../sources/yt-dlp'; @@ -118,13 +120,15 @@ export async function systemRoutes(fastify: FastifyInstance): Promise { */ fastify.get('/api/v1/system/settings', async (_request, _reply) => { const db = fastify.db; - const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE, APP_NFO_ENABLED]); + const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE, APP_NFO_ENABLED, APP_TIMEZONE, APP_THEME]); const response: AppSettingsResponse = { checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10), concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10), outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}', nfoEnabled: settings[APP_NFO_ENABLED] === 'true', + timezone: settings[APP_TIMEZONE] ?? 'UTC', + theme: (settings[APP_THEME] === 'light' ? 'light' : 'dark') as 'dark' | 'light', }; return response; @@ -137,7 +141,7 @@ export async function systemRoutes(fastify: FastifyInstance): Promise { */ fastify.put('/api/v1/system/settings', async (request, reply) => { const db = fastify.db; - const body = request.body as { checkInterval?: number; concurrentDownloads?: number; outputTemplate?: string; nfoEnabled?: boolean }; + const body = request.body as { checkInterval?: number; concurrentDownloads?: number; outputTemplate?: string; nfoEnabled?: boolean; timezone?: string; theme?: string }; // Validate if (body.checkInterval !== undefined) { @@ -198,14 +202,36 @@ export async function systemRoutes(fastify: FastifyInstance): Promise { if (body.nfoEnabled !== undefined) { await setAppSetting(db, APP_NFO_ENABLED, body.nfoEnabled ? 'true' : 'false'); } + if (body.timezone !== undefined) { + if (typeof body.timezone !== 'string' || body.timezone.trim().length === 0) { + return reply.status(400).send({ + statusCode: 400, + error: 'Bad Request', + message: 'timezone must be a non-empty string', + }); + } + await setAppSetting(db, APP_TIMEZONE, body.timezone); + } + if (body.theme !== undefined) { + if (body.theme !== 'dark' && body.theme !== 'light') { + return reply.status(400).send({ + statusCode: 400, + error: 'Bad Request', + message: 'theme must be "dark" or "light"', + }); + } + await setAppSetting(db, APP_THEME, body.theme); + } // Return updated values - const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE, APP_NFO_ENABLED]); + const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE, APP_NFO_ENABLED, APP_TIMEZONE, APP_THEME]); const response: AppSettingsResponse = { checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10), concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10), outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}', nfoEnabled: settings[APP_NFO_ENABLED] === 'true', + timezone: settings[APP_TIMEZONE] ?? 'UTC', + theme: (settings[APP_THEME] === 'light' ? 'light' : 'dark') as 'dark' | 'light', }; return response; diff --git a/src/types/api.ts b/src/types/api.ts index 6195648..9fd9040 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -83,6 +83,8 @@ export interface AppSettingsResponse { concurrentDownloads: number; outputTemplate: string; nfoEnabled: boolean; + timezone: string; + theme: 'dark' | 'light'; } /** Channel with aggregated content counts — returned by GET /api/v1/channel. */