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:
parent
daf892edad
commit
98c3d73c69
6 changed files with 239 additions and 6 deletions
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue