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_CONCURRENT_DOWNLOADS = 'app.concurrent_downloads';
|
||||||
export const APP_OUTPUT_TEMPLATE = 'app.output_template';
|
export const APP_OUTPUT_TEMPLATE = 'app.output_template';
|
||||||
export const APP_NFO_ENABLED = 'app.nfo_enabled';
|
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';
|
export const YTDLP_LAST_UPDATED = 'ytdlp.last_updated';
|
||||||
|
|
||||||
// ── Read / Write ──
|
// ── Read / Write ──
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,13 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useCallback, useMemo, useEffect } from '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 } from 'lucide-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 {
|
import {
|
||||||
useFormatProfiles,
|
useFormatProfiles,
|
||||||
useCreateFormatProfile,
|
useCreateFormatProfile,
|
||||||
|
|
@ -106,8 +106,28 @@ export function SettingsPage() {
|
||||||
const [concurrentDownloads, setConcurrentDownloads] = useState<number | ''>('');
|
const [concurrentDownloads, setConcurrentDownloads] = useState<number | ''>('');
|
||||||
const [outputTemplate, setOutputTemplate] = useState('');
|
const [outputTemplate, setOutputTemplate] = useState('');
|
||||||
const [nfoEnabled, setNfoEnabled] = useState(false);
|
const [nfoEnabled, setNfoEnabled] = useState(false);
|
||||||
|
const [timezone, setTimezone] = useState('UTC');
|
||||||
|
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||||
const [settingsSaveFlash, setSettingsSaveFlash] = useState(false);
|
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
|
// Initialize local state from fetched app settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (appSettings) {
|
if (appSettings) {
|
||||||
|
|
@ -115,9 +135,17 @@ export function SettingsPage() {
|
||||||
setConcurrentDownloads(appSettings.concurrentDownloads);
|
setConcurrentDownloads(appSettings.concurrentDownloads);
|
||||||
setOutputTemplate(appSettings.outputTemplate);
|
setOutputTemplate(appSettings.outputTemplate);
|
||||||
setNfoEnabled(appSettings.nfoEnabled);
|
setNfoEnabled(appSettings.nfoEnabled);
|
||||||
|
setTimezone(appSettings.timezone);
|
||||||
|
setTheme(appSettings.theme);
|
||||||
}
|
}
|
||||||
}, [appSettings]);
|
}, [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 =
|
const settingsDirty =
|
||||||
checkInterval !== '' &&
|
checkInterval !== '' &&
|
||||||
concurrentDownloads !== '' &&
|
concurrentDownloads !== '' &&
|
||||||
|
|
@ -125,7 +153,9 @@ export function SettingsPage() {
|
||||||
(Number(checkInterval) !== appSettings.checkInterval ||
|
(Number(checkInterval) !== appSettings.checkInterval ||
|
||||||
Number(concurrentDownloads) !== appSettings.concurrentDownloads ||
|
Number(concurrentDownloads) !== appSettings.concurrentDownloads ||
|
||||||
outputTemplate !== appSettings.outputTemplate ||
|
outputTemplate !== appSettings.outputTemplate ||
|
||||||
nfoEnabled !== appSettings.nfoEnabled);
|
nfoEnabled !== appSettings.nfoEnabled ||
|
||||||
|
timezone !== appSettings.timezone ||
|
||||||
|
theme !== appSettings.theme);
|
||||||
|
|
||||||
const settingsValid =
|
const settingsValid =
|
||||||
checkInterval !== '' &&
|
checkInterval !== '' &&
|
||||||
|
|
@ -177,6 +207,8 @@ export function SettingsPage() {
|
||||||
concurrentDownloads: Number(concurrentDownloads),
|
concurrentDownloads: Number(concurrentDownloads),
|
||||||
outputTemplate,
|
outputTemplate,
|
||||||
nfoEnabled,
|
nfoEnabled,
|
||||||
|
timezone,
|
||||||
|
theme,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -1033,6 +1065,116 @@ export function SettingsPage() {
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -97,3 +97,57 @@
|
||||||
--glass-bg: rgba(20, 22, 30, 0.6);
|
--glass-bg: rgba(20, 22, 30, 0.6);
|
||||||
--glass-border: rgba(255, 255, 255, 0.08);
|
--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_CONCURRENT_DOWNLOADS,
|
||||||
APP_OUTPUT_TEMPLATE,
|
APP_OUTPUT_TEMPLATE,
|
||||||
APP_NFO_ENABLED,
|
APP_NFO_ENABLED,
|
||||||
|
APP_TIMEZONE,
|
||||||
|
APP_THEME,
|
||||||
YTDLP_LAST_UPDATED,
|
YTDLP_LAST_UPDATED,
|
||||||
} from '../../db/repositories/system-config-repository';
|
} from '../../db/repositories/system-config-repository';
|
||||||
import { getYtDlpVersion, updateYtDlp } from '../../sources/yt-dlp';
|
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) => {
|
fastify.get('/api/v1/system/settings', async (_request, _reply) => {
|
||||||
const db = fastify.db;
|
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 = {
|
const response: AppSettingsResponse = {
|
||||||
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10),
|
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10),
|
||||||
concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10),
|
concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10),
|
||||||
outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}',
|
outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}',
|
||||||
nfoEnabled: settings[APP_NFO_ENABLED] === 'true',
|
nfoEnabled: settings[APP_NFO_ENABLED] === 'true',
|
||||||
|
timezone: settings[APP_TIMEZONE] ?? 'UTC',
|
||||||
|
theme: (settings[APP_THEME] === 'light' ? 'light' : 'dark') as 'dark' | 'light',
|
||||||
};
|
};
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|
@ -137,7 +141,7 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
*/
|
*/
|
||||||
fastify.put('/api/v1/system/settings', async (request, reply) => {
|
fastify.put('/api/v1/system/settings', async (request, reply) => {
|
||||||
const db = fastify.db;
|
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
|
// Validate
|
||||||
if (body.checkInterval !== undefined) {
|
if (body.checkInterval !== undefined) {
|
||||||
|
|
@ -198,14 +202,36 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
if (body.nfoEnabled !== undefined) {
|
if (body.nfoEnabled !== undefined) {
|
||||||
await setAppSetting(db, APP_NFO_ENABLED, body.nfoEnabled ? 'true' : 'false');
|
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
|
// 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 = {
|
const response: AppSettingsResponse = {
|
||||||
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10),
|
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10),
|
||||||
concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10),
|
concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10),
|
||||||
outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}',
|
outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}',
|
||||||
nfoEnabled: settings[APP_NFO_ENABLED] === 'true',
|
nfoEnabled: settings[APP_NFO_ENABLED] === 'true',
|
||||||
|
timezone: settings[APP_TIMEZONE] ?? 'UTC',
|
||||||
|
theme: (settings[APP_THEME] === 'light' ? 'light' : 'dark') as 'dark' | 'light',
|
||||||
};
|
};
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,8 @@ export interface AppSettingsResponse {
|
||||||
concurrentDownloads: number;
|
concurrentDownloads: number;
|
||||||
outputTemplate: string;
|
outputTemplate: string;
|
||||||
nfoEnabled: boolean;
|
nfoEnabled: boolean;
|
||||||
|
timezone: string;
|
||||||
|
theme: 'dark' | 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Channel with aggregated content counts — returned by GET /api/v1/channel. */
|
/** Channel with aggregated content counts — returned by GET /api/v1/channel. */
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue