feat: Added timezone selector and dark/light theme toggle to Settings p…

- "src/server/routes/system.ts"
- "src/db/repositories/system-config-repository.ts"
- "src/types/api.ts"
- "src/frontend/src/pages/Settings.tsx"
- "src/frontend/src/styles/theme.css"
- "src/frontend/index.html"

GSD-Task: S08/T01
This commit is contained in:
jlightner 2026-04-04 07:19:15 +00:00
parent daf892edad
commit 98c3d73c69
6 changed files with 239 additions and 6 deletions

View file

@ -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 ──

View file

@ -7,6 +7,13 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<script>
// Apply saved theme instantly to prevent flash of wrong theme
(function() {
var t = localStorage.getItem('tubearr-theme');
if (t === 'light') document.documentElement.dataset.theme = 'light';
})();
</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View file

@ -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<number | ''>('');
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() {
)}
</td>
</tr>
{/* Timezone selector */}
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ width: '200px', padding: 'var(--space-4)', fontWeight: 500, color: 'var(--text-primary)', verticalAlign: 'middle' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<Clock size={14} />
Timezone
</span>
</td>
<td style={{ padding: 'var(--space-4)' }}>
{appSettingsLoading ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)', color: 'var(--text-muted)' }}>
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
Loading
</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', maxWidth: 320 }}>
<input
type="text"
value={tzSearch}
onChange={(e) => 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)',
}}
/>
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
aria-label="Timezone"
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)',
}}
>
{filteredTimezones.map(tz => (
<option key={tz} value={tz}>{tz.replace(/_/g, ' ')}</option>
))}
</select>
</div>
)}
</td>
</tr>
{/* Theme toggle */}
<tr>
<td style={{ width: '200px', padding: 'var(--space-4)', fontWeight: 500, color: 'var(--text-primary)', verticalAlign: 'middle' }}>
Theme
</td>
<td style={{ padding: 'var(--space-4)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<button
type="button"
onClick={() => setTheme('dark')}
aria-label="Dark theme"
aria-pressed={theme === 'dark'}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
border: `1px solid ${theme === 'dark' ? 'var(--accent)' : 'var(--border)'}`,
backgroundColor: theme === 'dark' ? 'var(--accent-subtle)' : 'var(--bg-main)',
color: theme === 'dark' ? 'var(--accent)' : 'var(--text-secondary)',
fontSize: 'var(--font-size-sm)',
fontWeight: theme === 'dark' ? 600 : 400,
cursor: 'pointer',
transition: 'all var(--transition-fast)',
}}
>
<Moon size={14} />
Dark
</button>
<button
type="button"
onClick={() => setTheme('light')}
aria-label="Light theme"
aria-pressed={theme === 'light'}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
border: `1px solid ${theme === 'light' ? 'var(--accent)' : 'var(--border)'}`,
backgroundColor: theme === 'light' ? 'var(--accent-subtle)' : 'var(--bg-main)',
color: theme === 'light' ? 'var(--accent)' : 'var(--text-secondary)',
fontSize: 'var(--font-size-sm)',
fontWeight: theme === 'light' ? 600 : 400,
cursor: 'pointer',
transition: 'all var(--transition-fast)',
}}
>
<Sun size={14} />
Light
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>

View file

@ -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);
}

View file

@ -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<void> {
*/
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<void> {
*/
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<void> {
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;

View file

@ -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. */