From 7373eeb501b6f7e5db0f3f6d947aa51efe811212 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 23 Mar 2026 08:53:18 +0800 Subject: [PATCH] feat: add backup start time configuration and theme switch functionality - Introduced BACKUP_DEFAULT_START_TIME constant for backup scheduling. - Updated BackupScheduleConfig interface to include startTime. - Implemented normalizeStartTime function for validating and normalizing start time input. - Enhanced backup settings parsing to accommodate start time. - Added start time input field in BackupDestinationDetail component. - Created ThemeSwitch component for toggling between light and dark themes. - Integrated theme preference management in App component. - Updated styles for dark mode support across the application. - Added translations for theme toggle and backup start time labels. --- shared/backup-schema.ts | 3 + src/services/backup-config.ts | 99 +++- webapp/src/App.tsx | 53 ++ .../src/components/AppAuthenticatedShell.tsx | 8 + webapp/src/components/ThemeSwitch.tsx | 29 + .../backup-center/BackupDestinationDetail.tsx | 17 + webapp/src/lib/i18n.ts | 14 +- webapp/src/styles.css | 508 +++++++++++++++++- 8 files changed, 722 insertions(+), 9 deletions(-) create mode 100644 webapp/src/components/ThemeSwitch.tsx diff --git a/shared/backup-schema.ts b/shared/backup-schema.ts index 82fd32b..9e58a74 100644 --- a/shared/backup-schema.ts +++ b/shared/backup-schema.ts @@ -3,6 +3,7 @@ export const BACKUP_DEFAULT_RETENTION_COUNT = 30; export const BACKUP_DEFAULT_E3_REGION = 'auto'; export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden'; export const BACKUP_DEFAULT_INTERVAL_HOURS = 24; +export const BACKUP_DEFAULT_START_TIME = '03:00'; export type BackupDestinationType = 'e3' | 'webdav'; @@ -40,6 +41,7 @@ export interface BackupRuntimeState { export interface BackupScheduleConfig { enabled: boolean; intervalHours: number; + startTime: string; timezone: string; retentionCount: number | null; } @@ -82,6 +84,7 @@ export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFA return { enabled: false, intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS, + startTime: BACKUP_DEFAULT_START_TIME, timezone, retentionCount: BACKUP_DEFAULT_RETENTION_COUNT, }; diff --git a/src/services/backup-config.ts b/src/services/backup-config.ts index 99065ee..3ea0d9b 100644 --- a/src/services/backup-config.ts +++ b/src/services/backup-config.ts @@ -8,6 +8,7 @@ import { } from './backup-settings-crypto'; import { BACKUP_DEFAULT_INTERVAL_HOURS, + BACKUP_DEFAULT_START_TIME, BACKUP_DEFAULT_TIMEZONE, type BackupDestinationConfig, type BackupDestinationRecord, @@ -90,6 +91,20 @@ function normalizeIntervalHours(value: unknown, fallback: number = BACKUP_DEFAUL return raw; } +function normalizeStartTime(value: unknown, fallback: string = BACKUP_DEFAULT_START_TIME): string { + const raw = asTrimmedString(value) || fallback; + const match = raw.match(/^(\d{1,2})(?::(\d{1,2}))?$/); + if (!match) { + throw new Error('Backup start time must be in HH:mm format'); + } + const hour = Number(match[1]); + const minute = Number(match[2] ?? '0'); + if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { + throw new Error('Backup start time must be in HH:mm format'); + } + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; +} + function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination { const source = isPlainObject(value) ? value : {}; const endpoint = asTrimmedString(source.endpoint); @@ -219,6 +234,10 @@ function normalizeDestinationRecord( scheduleSource.intervalHours ?? previousSchedule.intervalHours, previousSchedule.intervalHours || BACKUP_DEFAULT_INTERVAL_HOURS ), + startTime: normalizeStartTime( + scheduleSource.startTime ?? previousSchedule.startTime, + previousSchedule.startTime || BACKUP_DEFAULT_START_TIME + ), timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE), retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount), }; @@ -259,6 +278,7 @@ function parseLegacyBackupSettings(rawValue: Record, fallbackTi schedule: { enabled: !!rawValue.enabled, intervalHours, + startTime: BACKUP_DEFAULT_START_TIME, timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE), retentionCount: 30, }, @@ -326,6 +346,7 @@ export function parseBackupSettings(raw: string | null, fallbackTimezone: string schedule: { enabled: scheduleEnabled, intervalHours: globalIntervalHours, + startTime: BACKUP_DEFAULT_START_TIME, timezone: globalTimezone, retentionCount: 30, }, @@ -495,15 +516,87 @@ export function getBackupLocalTime(date: Date, timezone: string): string { return `${parts.hour}:${parts.minute}`; } +function parseLocalDateKey(dateKey: string): { year: number; month: number; day: number } | null { + const match = String(dateKey || '').match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null; + return { year, month, day }; +} + +function getUtcDateForLocalTime(timezone: string, year: number, month: number, day: number, hour: number, minute: number): Date { + const utcGuess = Date.UTC(year, month - 1, day, hour, minute, 0, 0); + const actual = getDateTimeParts(new Date(utcGuess), timezone); + const actualUtc = Date.UTC( + Number(actual.year), + Number(actual.month) - 1, + Number(actual.day), + Number(actual.hour), + Number(actual.minute), + 0, + 0 + ); + const desiredUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0); + return new Date(utcGuess - (actualUtc - desiredUtc)); +} + +function getBackupSlotStartsForLocalDay( + dateKey: string, + timezone: string, + startTime: string, + intervalHours: number +): Date[] { + const parsedDate = parseLocalDateKey(dateKey); + const parsedTime = normalizeStartTime(startTime).split(':').map((value) => Number(value)); + if (!parsedDate || parsedTime.length !== 2) return []; + + const [hour, minute] = parsedTime; + const firstSlot = getUtcDateForLocalTime(timezone, parsedDate.year, parsedDate.month, parsedDate.day, hour, minute); + const nextLocalDay = new Date(Date.UTC(parsedDate.year, parsedDate.month - 1, parsedDate.day, 0, 0, 0, 0)); + nextLocalDay.setUTCDate(nextLocalDay.getUTCDate() + 1); + const nextDay = getUtcDateForLocalTime( + timezone, + nextLocalDay.getUTCFullYear(), + nextLocalDay.getUTCMonth() + 1, + nextLocalDay.getUTCDate(), + 0, + 0 + ); + const intervalMs = intervalHours * 60 * 60 * 1000; + const slots: Date[] = []; + + for (let slotMs = firstSlot.getTime(); slotMs < nextDay.getTime(); slotMs += intervalMs) { + slots.push(new Date(slotMs)); + } + return slots; +} + export function isBackupDueNow( destination: BackupDestinationRecord, now: Date, windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES ): boolean { if (!destination.schedule.enabled) return false; - const intervalMs = destination.schedule.intervalHours * 60 * 60 * 1000; const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000; const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null; - if (!lastAttemptAt || !Number.isFinite(lastAttemptAt.getTime())) return true; - return now.getTime() - lastAttemptAt.getTime() >= Math.max(0, intervalMs - toleranceMs); + const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime()) + ? lastAttemptAt.getTime() + : Number.NEGATIVE_INFINITY; + const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone); + const slotStarts = getBackupSlotStartsForLocalDay( + localDateKey, + destination.schedule.timezone, + destination.schedule.startTime, + destination.schedule.intervalHours + ); + + for (const slotStart of slotStarts) { + const slotStartMs = slotStart.getTime(); + if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue; + if (lastAttemptMs >= slotStartMs) return false; + return true; + } + return false; } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 644555c..7c10ef6 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -57,11 +57,26 @@ const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export const IMPORT_ROUTE_ALIASES: ReadonlySet = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE)); const SETTINGS_HOME_ROUTE = '/settings'; const SETTINGS_ACCOUNT_ROUTE = '/settings/account'; +const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1'; const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e); const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; +type ThemePreference = 'system' | 'light' | 'dark'; + +function readThemePreference(): ThemePreference { + if (typeof window === 'undefined') return 'system'; + const stored = String(window.localStorage.getItem(THEME_STORAGE_KEY) || '').trim(); + if (stored === 'light' || stored === 'dark' || stored === 'system') return stored; + return 'system'; +} + +function resolveSystemTheme(): 'light' | 'dark' { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + export default function App() { const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []); const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []); @@ -100,6 +115,8 @@ export default function App() { const [disableTotpOpen, setDisableTotpOpen] = useState(false); const [disableTotpPassword, setDisableTotpPassword] = useState(''); const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); + const [themePreference, setThemePreference] = useState(() => readThemePreference()); + const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme()); const [confirm, setConfirm] = useState(null); const [mobileLayout, setMobileLayout] = useState(false); @@ -175,6 +192,39 @@ export default function App() { return () => media.removeListener(sync); }, []); + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + const media = window.matchMedia('(prefers-color-scheme: dark)'); + const sync = () => setSystemTheme(media.matches ? 'dark' : 'light'); + sync(); + if (typeof media.addEventListener === 'function') { + media.addEventListener('change', sync); + return () => media.removeEventListener('change', sync); + } + media.addListener(sync); + return () => media.removeListener(sync); + }, []); + + const resolvedTheme = themePreference === 'system' ? systemTheme : themePreference; + + useEffect(() => { + if (typeof document === 'undefined') return; + document.documentElement.dataset.theme = resolvedTheme; + document.documentElement.style.colorScheme = resolvedTheme; + }, [resolvedTheme]); + + useEffect(() => { + if (typeof window === 'undefined') return; + window.localStorage.setItem(THEME_STORAGE_KEY, themePreference); + }, [themePreference]); + + function handleToggleTheme() { + setThemePreference((prev) => { + const current = prev === 'system' ? systemTheme : prev; + return current === 'dark' ? 'light' : 'dark'; + }); + } + function setSession(next: SessionState | null) { sessionRef.current = next; setSessionState(next); @@ -1135,8 +1185,11 @@ export default function App() { settingsAccountRoute={SETTINGS_ACCOUNT_ROUTE} importRoute={IMPORT_ROUTE} isImportRoute={isImportRoute} + darkMode={resolvedTheme === 'dark'} + themeToggleTitle={resolvedTheme === 'dark' ? t('txt_switch_to_light_mode') : t('txt_switch_to_dark_mode')} onLock={handleLock} onLogout={handleLogout} + onToggleTheme={handleToggleTheme} mainRoutesProps={mainRoutesProps} /> diff --git a/webapp/src/components/AppAuthenticatedShell.tsx b/webapp/src/components/AppAuthenticatedShell.tsx index 761e71b..5049a9c 100644 --- a/webapp/src/components/AppAuthenticatedShell.tsx +++ b/webapp/src/components/AppAuthenticatedShell.tsx @@ -1,6 +1,7 @@ import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import { Link } from 'wouter'; import AppMainRoutes from '@/components/AppMainRoutes'; +import ThemeSwitch from '@/components/ThemeSwitch'; import type { AppMainRoutesProps } from '@/components/AppMainRoutes'; import { t } from '@/lib/i18n'; import type { Profile } from '@/lib/types'; @@ -15,8 +16,11 @@ interface AppAuthenticatedShellProps { settingsAccountRoute: string; importRoute: string; isImportRoute: boolean; + darkMode: boolean; + themeToggleTitle: string; onLock: () => void; onLogout: () => void; + onToggleTheme: () => void; mainRoutesProps: AppMainRoutesProps; } @@ -35,6 +39,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {props.profile?.email} + @@ -49,6 +54,9 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) )} +
+ +
diff --git a/webapp/src/components/ThemeSwitch.tsx b/webapp/src/components/ThemeSwitch.tsx new file mode 100644 index 0000000..08ebce3 --- /dev/null +++ b/webapp/src/components/ThemeSwitch.tsx @@ -0,0 +1,29 @@ +interface ThemeSwitchProps { + checked: boolean; + title: string; + onToggle: () => void; +} + +export default function ThemeSwitch(props: ThemeSwitchProps) { + return ( +
+ +
+ ); +} diff --git a/webapp/src/components/backup-center/BackupDestinationDetail.tsx b/webapp/src/components/backup-center/BackupDestinationDetail.tsx index 8f27a18..7a27293 100644 --- a/webapp/src/components/backup-center/BackupDestinationDetail.tsx +++ b/webapp/src/components/backup-center/BackupDestinationDetail.tsx @@ -256,6 +256,23 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) { +