mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
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.
This commit is contained in:
@@ -3,6 +3,7 @@ export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
|||||||
export const BACKUP_DEFAULT_E3_REGION = 'auto';
|
export const BACKUP_DEFAULT_E3_REGION = 'auto';
|
||||||
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
||||||
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
||||||
|
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||||
|
|
||||||
export type BackupDestinationType = 'e3' | 'webdav';
|
export type BackupDestinationType = 'e3' | 'webdav';
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export interface BackupRuntimeState {
|
|||||||
export interface BackupScheduleConfig {
|
export interface BackupScheduleConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
intervalHours: number;
|
intervalHours: number;
|
||||||
|
startTime: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
retentionCount: number | null;
|
retentionCount: number | null;
|
||||||
}
|
}
|
||||||
@@ -82,6 +84,7 @@ export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFA
|
|||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS,
|
intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS,
|
||||||
|
startTime: BACKUP_DEFAULT_START_TIME,
|
||||||
timezone,
|
timezone,
|
||||||
retentionCount: BACKUP_DEFAULT_RETENTION_COUNT,
|
retentionCount: BACKUP_DEFAULT_RETENTION_COUNT,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from './backup-settings-crypto';
|
} from './backup-settings-crypto';
|
||||||
import {
|
import {
|
||||||
BACKUP_DEFAULT_INTERVAL_HOURS,
|
BACKUP_DEFAULT_INTERVAL_HOURS,
|
||||||
|
BACKUP_DEFAULT_START_TIME,
|
||||||
BACKUP_DEFAULT_TIMEZONE,
|
BACKUP_DEFAULT_TIMEZONE,
|
||||||
type BackupDestinationConfig,
|
type BackupDestinationConfig,
|
||||||
type BackupDestinationRecord,
|
type BackupDestinationRecord,
|
||||||
@@ -90,6 +91,20 @@ function normalizeIntervalHours(value: unknown, fallback: number = BACKUP_DEFAUL
|
|||||||
return raw;
|
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 {
|
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
|
||||||
const source = isPlainObject(value) ? value : {};
|
const source = isPlainObject(value) ? value : {};
|
||||||
const endpoint = asTrimmedString(source.endpoint);
|
const endpoint = asTrimmedString(source.endpoint);
|
||||||
@@ -219,6 +234,10 @@ function normalizeDestinationRecord(
|
|||||||
scheduleSource.intervalHours ?? previousSchedule.intervalHours,
|
scheduleSource.intervalHours ?? previousSchedule.intervalHours,
|
||||||
previousSchedule.intervalHours || BACKUP_DEFAULT_INTERVAL_HOURS
|
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),
|
timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||||
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
||||||
};
|
};
|
||||||
@@ -259,6 +278,7 @@ function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTi
|
|||||||
schedule: {
|
schedule: {
|
||||||
enabled: !!rawValue.enabled,
|
enabled: !!rawValue.enabled,
|
||||||
intervalHours,
|
intervalHours,
|
||||||
|
startTime: BACKUP_DEFAULT_START_TIME,
|
||||||
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||||
retentionCount: 30,
|
retentionCount: 30,
|
||||||
},
|
},
|
||||||
@@ -326,6 +346,7 @@ export function parseBackupSettings(raw: string | null, fallbackTimezone: string
|
|||||||
schedule: {
|
schedule: {
|
||||||
enabled: scheduleEnabled,
|
enabled: scheduleEnabled,
|
||||||
intervalHours: globalIntervalHours,
|
intervalHours: globalIntervalHours,
|
||||||
|
startTime: BACKUP_DEFAULT_START_TIME,
|
||||||
timezone: globalTimezone,
|
timezone: globalTimezone,
|
||||||
retentionCount: 30,
|
retentionCount: 30,
|
||||||
},
|
},
|
||||||
@@ -495,15 +516,87 @@ export function getBackupLocalTime(date: Date, timezone: string): string {
|
|||||||
return `${parts.hour}:${parts.minute}`;
|
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(
|
export function isBackupDueNow(
|
||||||
destination: BackupDestinationRecord,
|
destination: BackupDestinationRecord,
|
||||||
now: Date,
|
now: Date,
|
||||||
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
|
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!destination.schedule.enabled) return false;
|
if (!destination.schedule.enabled) return false;
|
||||||
const intervalMs = destination.schedule.intervalHours * 60 * 60 * 1000;
|
|
||||||
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
||||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
||||||
if (!lastAttemptAt || !Number.isFinite(lastAttemptAt.getTime())) return true;
|
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
||||||
return now.getTime() - lastAttemptAt.getTime() >= Math.max(0, intervalMs - toleranceMs);
|
? 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,11 +57,26 @@ const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export
|
|||||||
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
||||||
const SETTINGS_HOME_ROUTE = '/settings';
|
const SETTINGS_HOME_ROUTE = '/settings';
|
||||||
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
||||||
|
const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1';
|
||||||
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
|
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
|
||||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
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() {
|
export default function App() {
|
||||||
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
||||||
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
||||||
@@ -100,6 +115,8 @@ export default function App() {
|
|||||||
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
||||||
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
||||||
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
||||||
|
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
||||||
|
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
||||||
|
|
||||||
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
|
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
|
||||||
const [mobileLayout, setMobileLayout] = useState(false);
|
const [mobileLayout, setMobileLayout] = useState(false);
|
||||||
@@ -175,6 +192,39 @@ export default function App() {
|
|||||||
return () => media.removeListener(sync);
|
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) {
|
function setSession(next: SessionState | null) {
|
||||||
sessionRef.current = next;
|
sessionRef.current = next;
|
||||||
setSessionState(next);
|
setSessionState(next);
|
||||||
@@ -1135,8 +1185,11 @@ export default function App() {
|
|||||||
settingsAccountRoute={SETTINGS_ACCOUNT_ROUTE}
|
settingsAccountRoute={SETTINGS_ACCOUNT_ROUTE}
|
||||||
importRoute={IMPORT_ROUTE}
|
importRoute={IMPORT_ROUTE}
|
||||||
isImportRoute={isImportRoute}
|
isImportRoute={isImportRoute}
|
||||||
|
darkMode={resolvedTheme === 'dark'}
|
||||||
|
themeToggleTitle={resolvedTheme === 'dark' ? t('txt_switch_to_light_mode') : t('txt_switch_to_dark_mode')}
|
||||||
onLock={handleLock}
|
onLock={handleLock}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
onToggleTheme={handleToggleTheme}
|
||||||
mainRoutesProps={mainRoutesProps}
|
mainRoutesProps={mainRoutesProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { 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 { Link } from 'wouter';
|
||||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||||
|
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||||
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Profile } from '@/lib/types';
|
import type { Profile } from '@/lib/types';
|
||||||
@@ -15,8 +16,11 @@ interface AppAuthenticatedShellProps {
|
|||||||
settingsAccountRoute: string;
|
settingsAccountRoute: string;
|
||||||
importRoute: string;
|
importRoute: string;
|
||||||
isImportRoute: boolean;
|
isImportRoute: boolean;
|
||||||
|
darkMode: boolean;
|
||||||
|
themeToggleTitle: string;
|
||||||
onLock: () => void;
|
onLock: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
onToggleTheme: () => void;
|
||||||
mainRoutesProps: AppMainRoutesProps;
|
mainRoutesProps: AppMainRoutesProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +39,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<ShieldUser size={16} />
|
<ShieldUser size={16} />
|
||||||
<span>{props.profile?.email}</span>
|
<span>{props.profile?.email}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
|
||||||
<button type="button" className="btn btn-secondary small" onClick={props.onLock}>
|
<button type="button" className="btn btn-secondary small" onClick={props.onLock}>
|
||||||
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
|
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
|
||||||
</button>
|
</button>
|
||||||
@@ -49,6 +54,9 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<FolderIcon size={16} className="btn-icon" />
|
<FolderIcon size={16} className="btn-icon" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<div className="mobile-theme-btn">
|
||||||
|
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
|
||||||
|
</div>
|
||||||
<button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={props.onLock}>
|
<button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={props.onLock}>
|
||||||
<Lock size={14} className="btn-icon" />
|
<Lock size={14} className="btn-icon" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
interface ThemeSwitchProps {
|
||||||
|
checked: boolean;
|
||||||
|
title: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThemeSwitch(props: ThemeSwitchProps) {
|
||||||
|
return (
|
||||||
|
<div className="theme-switch-wrap" title={props.title}>
|
||||||
|
<label className="theme-switch" aria-label={props.title}>
|
||||||
|
<span className="sun" aria-hidden="true">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<g fill="#ffd43b">
|
||||||
|
<circle r={5} cy={12} cx={12} />
|
||||||
|
<path d="m21 13h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm-17 0h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm13.66-5.66a1 1 0 0 1 -.66-.29 1 1 0 0 1 0-1.41l.71-.71a1 1 0 1 1 1.41 1.41l-.71.71a1 1 0 0 1 -.75.29zm-12.02 12.02a1 1 0 0 1 -.71-.29 1 1 0 0 1 0-1.41l.71-.66a1 1 0 0 1 1.41 1.41l-.71.71a1 1 0 0 1 -.7.24zm6.36-14.36a1 1 0 0 1 -1-1v-1a1 1 0 0 1 2 0v1a1 1 0 0 1 -1 1zm0 17a1 1 0 0 1 -1-1v-1a1 1 0 0 1 2 0v1a1 1 0 0 1 -1 1zm-5.66-14.66a1 1 0 0 1 -.7-.29l-.71-.71a1 1 0 0 1 1.41-1.41l.71.71a1 1 0 0 1 0 1.41 1 1 0 0 1 -.71.29zm12.02 12.02a1 1 0 0 1 -.7-.29l-.66-.71a1 1 0 0 1 1.36-1.36l.71.71a1 1 0 0 1 0 1.41 1 1 0 0 1 -.71.24z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="moon" aria-hidden="true">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
|
||||||
|
<path d="m223.5 32c-123.5 0-223.5 100.3-223.5 224s100 224 223.5 224c60.6 0 115.5-24.2 155.8-63.4 5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6-96.9 0-175.5-78.8-175.5-176 0-65.8 36-123.1 89.3-153.3 6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" className="theme-switch-input" checked={props.checked} onInput={props.onToggle} />
|
||||||
|
<span className="theme-switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -256,6 +256,23 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_start_time')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="time"
|
||||||
|
step={300}
|
||||||
|
value={props.selectedDestination.schedule.startTime || '03:00'}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
schedule: {
|
||||||
|
...destination.schedule,
|
||||||
|
startTime: (event.currentTarget as HTMLInputElement).value || '03:00',
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_backup_timezone')}</span>
|
<span>{t('txt_backup_timezone')}</span>
|
||||||
<select
|
<select
|
||||||
|
|||||||
+10
-4
@@ -140,6 +140,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_backup_type: "Backup Type",
|
txt_backup_type: "Backup Type",
|
||||||
txt_backup_destination_reserved: "Reserved Slot",
|
txt_backup_destination_reserved: "Reserved Slot",
|
||||||
txt_backup_time: "Backup Time",
|
txt_backup_time: "Backup Time",
|
||||||
|
txt_backup_start_time: "Start Time",
|
||||||
txt_backup_timezone: "Timezone",
|
txt_backup_timezone: "Timezone",
|
||||||
txt_backup_interval_hours: "Every",
|
txt_backup_interval_hours: "Every",
|
||||||
txt_backup_interval_hours_suffix: "hours",
|
txt_backup_interval_hours_suffix: "hours",
|
||||||
@@ -164,10 +165,10 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_backup_include_attachments_help_button: "Attachment backup help",
|
txt_backup_include_attachments_help_button: "Attachment backup help",
|
||||||
txt_backup_include_attachments_help: "Attachments are stored incrementally in the remote attachments folder, so later backups usually only upload new files. Deleting an attachment locally does not remove earlier remote copies. During restore, NodeWarden reads the required files from the attachments folder and skips any attachment that is no longer available.",
|
txt_backup_include_attachments_help: "Attachments are stored incrementally in the remote attachments folder, so later backups usually only upload new files. Deleting an attachment locally does not remove earlier remote copies. During restore, NodeWarden reads the required files from the attachments folder and skips any attachment that is no longer available.",
|
||||||
txt_backup_enable_schedule: "Enable automatic daily backup",
|
txt_backup_enable_schedule: "Enable automatic daily backup",
|
||||||
txt_backup_schedule_note: "The worker checks the schedule every 5 minutes and runs the backup as soon as the selected time window is reached.",
|
txt_backup_schedule_note: "The worker checks the schedule every 5 minutes. It starts at the selected time in the selected timezone, then repeats by the chosen hour interval, and resets from that start time each day.",
|
||||||
txt_backup_schedule_disabled: "Disabled",
|
txt_backup_schedule_disabled: "Disabled",
|
||||||
txt_backup_schedule_status: "Schedule",
|
txt_backup_schedule_status: "Schedule",
|
||||||
txt_backup_schedule_summary: "Daily at {time} ({timezone})",
|
txt_backup_schedule_summary: "Start at {time}, every {interval} hours ({timezone})",
|
||||||
txt_backup_schedule_empty: "No automatic backup plans are enabled yet.",
|
txt_backup_schedule_empty: "No automatic backup plans are enabled yet.",
|
||||||
txt_backup_last_success: "Last Success",
|
txt_backup_last_success: "Last Success",
|
||||||
txt_backup_last_target: "Last Target",
|
txt_backup_last_target: "Last Target",
|
||||||
@@ -572,6 +573,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_submit: "Submit",
|
txt_submit: "Submit",
|
||||||
txt_sync: "Sync",
|
txt_sync: "Sync",
|
||||||
txt_sync_vault: "Sync Vault",
|
txt_sync_vault: "Sync Vault",
|
||||||
|
txt_switch_to_dark_mode: "Switch to dark mode",
|
||||||
|
txt_switch_to_light_mode: "Switch to light mode",
|
||||||
txt_dash: "-",
|
txt_dash: "-",
|
||||||
txt_text: "Text",
|
txt_text: "Text",
|
||||||
txt_text_2fa_recovered: "2FA recovered",
|
txt_text_2fa_recovered: "2FA recovered",
|
||||||
@@ -777,6 +780,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_backup_type: '备份类型',
|
txt_backup_type: '备份类型',
|
||||||
txt_backup_destination_reserved: '预留位置',
|
txt_backup_destination_reserved: '预留位置',
|
||||||
txt_backup_time: '备份时间',
|
txt_backup_time: '备份时间',
|
||||||
|
txt_backup_start_time: '开始时间',
|
||||||
txt_backup_timezone: '时区',
|
txt_backup_timezone: '时区',
|
||||||
txt_backup_interval_hours: '每隔',
|
txt_backup_interval_hours: '每隔',
|
||||||
txt_backup_interval_hours_suffix: '小时',
|
txt_backup_interval_hours_suffix: '小时',
|
||||||
@@ -801,10 +805,10 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_backup_include_attachments_help_button: '附件备份说明',
|
txt_backup_include_attachments_help_button: '附件备份说明',
|
||||||
txt_backup_include_attachments_help: '附件会以增量方式保存在远端的 attachments 文件夹中,后续备份通常只上传新增文件。你在本地删除附件时,已经备份到远端的旧文件不会自动删除。恢复时会按需从 attachments 文件夹读取对应附件,找不到的附件会自动跳过。',
|
txt_backup_include_attachments_help: '附件会以增量方式保存在远端的 attachments 文件夹中,后续备份通常只上传新增文件。你在本地删除附件时,已经备份到远端的旧文件不会自动删除。恢复时会按需从 attachments 文件夹读取对应附件,找不到的附件会自动跳过。',
|
||||||
txt_backup_enable_schedule: '启用每日自动备份',
|
txt_backup_enable_schedule: '启用每日自动备份',
|
||||||
txt_backup_schedule_note: 'Worker 每 5 分钟检查一次计划,到达你设定的时间窗口后会尽快执行备份。',
|
txt_backup_schedule_note: 'Worker 每 5 分钟检查一次计划。会先按你选择的时区和开始时间起跑,再按小时间隔继续执行;到了下一天,会重新从开始时间开始。',
|
||||||
txt_backup_schedule_disabled: '未启用',
|
txt_backup_schedule_disabled: '未启用',
|
||||||
txt_backup_schedule_status: '计划状态',
|
txt_backup_schedule_status: '计划状态',
|
||||||
txt_backup_schedule_summary: '每天 {time}({timezone})',
|
txt_backup_schedule_summary: '从 {time} 开始,每隔 {interval} 小时({timezone})',
|
||||||
txt_backup_schedule_empty: '还没有启用任何自动备份计划',
|
txt_backup_schedule_empty: '还没有启用任何自动备份计划',
|
||||||
txt_backup_last_success: '上次成功时间',
|
txt_backup_last_success: '上次成功时间',
|
||||||
txt_backup_last_target: '上次备份位置',
|
txt_backup_last_target: '上次备份位置',
|
||||||
@@ -1201,6 +1205,8 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_totp_is_enabled_for_this_account: '此账户已启用 TOTP。',
|
txt_totp_is_enabled_for_this_account: '此账户已启用 TOTP。',
|
||||||
txt_total_items_count: '共 {count} 项',
|
txt_total_items_count: '共 {count} 项',
|
||||||
txt_totp_verify_failed: 'TOTP 验证失败',
|
txt_totp_verify_failed: 'TOTP 验证失败',
|
||||||
|
txt_switch_to_dark_mode: '切换到暗黑模式',
|
||||||
|
txt_switch_to_light_mode: '切换到明亮模式',
|
||||||
txt_trust_this_device_for_30_days: '信任此设备 30 天',
|
txt_trust_this_device_for_30_days: '信任此设备 30 天',
|
||||||
txt_type_type: '类型 {type}',
|
txt_type_type: '类型 {type}',
|
||||||
txt_unlock_details: '解锁详情',
|
txt_unlock_details: '解锁详情',
|
||||||
|
|||||||
+506
-2
@@ -1,16 +1,42 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #f3f5f8;
|
--bg: #f3f5f8;
|
||||||
--panel: #ffffff;
|
--panel: #ffffff;
|
||||||
|
--panel-soft: #f8fafc;
|
||||||
|
--panel-muted: #f5f7fb;
|
||||||
--line: #d7dde6;
|
--line: #d7dde6;
|
||||||
|
--line-soft: #e1e6ef;
|
||||||
--text: #0f172a;
|
--text: #0f172a;
|
||||||
|
--text-soft: #1e293b;
|
||||||
--muted: #667085;
|
--muted: #667085;
|
||||||
|
--muted-strong: #475569;
|
||||||
--primary: #2563eb;
|
--primary: #2563eb;
|
||||||
--primary-hover: #1d4ed8;
|
--primary-hover: #1d4ed8;
|
||||||
--danger: #e11d48;
|
--danger: #e11d48;
|
||||||
--danger-hover: #be123c;
|
--danger-hover: #be123c;
|
||||||
|
--overlay: rgba(15, 23, 42, 0.36);
|
||||||
|
--overlay-strong: rgba(15, 23, 42, 0.5);
|
||||||
--radius: 12px;
|
--radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--bg: #08111f;
|
||||||
|
--panel: #0f1b2d;
|
||||||
|
--panel-soft: #122033;
|
||||||
|
--panel-muted: #0c1728;
|
||||||
|
--line: #223249;
|
||||||
|
--line-soft: #1b2a40;
|
||||||
|
--text: #e5edf7;
|
||||||
|
--text-soft: #d4dfef;
|
||||||
|
--muted: #98a8bf;
|
||||||
|
--muted-strong: #b3c0d3;
|
||||||
|
--primary: #69a7ff;
|
||||||
|
--primary-hover: #8abaff;
|
||||||
|
--danger: #ff7a96;
|
||||||
|
--danger-hover: #ff97ac;
|
||||||
|
--overlay: rgba(2, 6, 23, 0.6);
|
||||||
|
--overlay-strong: rgba(2, 6, 23, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -27,6 +53,10 @@ body,
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
transition: background-color 0.25s ease, color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-screen {
|
.loading-screen {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -521,6 +551,90 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-theme-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 56px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch-input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(180deg, #dceaff, #c8dcff);
|
||||||
|
border: 1px solid #9dbbec;
|
||||||
|
transition: 0.25s ease;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch-slider::before {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
height: 26px;
|
||||||
|
width: 26px;
|
||||||
|
border-radius: 999px;
|
||||||
|
left: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
z-index: 2;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #edf4ff);
|
||||||
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.14);
|
||||||
|
transition: 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch .sun svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 32px;
|
||||||
|
z-index: 1;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch .moon svg {
|
||||||
|
fill: #5b86d6;
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
left: 7px;
|
||||||
|
z-index: 1;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch-input:checked + .theme-switch-slider {
|
||||||
|
background: linear-gradient(180deg, #173150, #122742);
|
||||||
|
border-color: #35527a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch-input:focus + .theme-switch-slider {
|
||||||
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch-input:checked + .theme-switch-slider::before {
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-actions .btn {
|
.topbar-actions .btn {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -1748,7 +1862,7 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backup-detail-schedule-grid {
|
.backup-detail-schedule-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-interval-row {
|
.backup-interval-row {
|
||||||
@@ -2668,7 +2782,8 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topbar-actions .user-chip,
|
.topbar-actions .user-chip,
|
||||||
.topbar-actions > .btn:not(.mobile-sidebar-toggle):not(.mobile-lock-btn) {
|
.topbar-actions > .btn:not(.mobile-sidebar-toggle):not(.mobile-lock-btn),
|
||||||
|
.topbar-actions > .theme-switch-wrap {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2689,6 +2804,16 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-theme-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-theme-btn .theme-switch {
|
||||||
|
transform: scale(0.8);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -3249,3 +3374,382 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
transform: translate(0, 0);
|
transform: translate(0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] body,
|
||||||
|
:root[data-theme='dark'] #root,
|
||||||
|
:root[data-theme='dark'] .app-page,
|
||||||
|
:root[data-theme='dark'] .auth-page {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .app-shell,
|
||||||
|
:root[data-theme='dark'] .auth-card,
|
||||||
|
:root[data-theme='dark'] .dialog,
|
||||||
|
:root[data-theme='dark'] .standalone-frame,
|
||||||
|
:root[data-theme='dark'] .jwt-warning-box,
|
||||||
|
:root[data-theme='dark'] .backup-operations-sidebar,
|
||||||
|
:root[data-theme='dark'] .backup-destination-sidebar,
|
||||||
|
:root[data-theme='dark'] .backup-detail-panel,
|
||||||
|
:root[data-theme='dark'] .settings-subcard,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation,
|
||||||
|
:root[data-theme='dark'] .backup-browser-card,
|
||||||
|
:root[data-theme='dark'] .backup-settings-card,
|
||||||
|
:root[data-theme='dark'] .backup-destination-card,
|
||||||
|
:root[data-theme='dark'] .backup-operation-card,
|
||||||
|
:root[data-theme='dark'] .list-panel,
|
||||||
|
:root[data-theme='dark'] .card,
|
||||||
|
:root[data-theme='dark'] .sidebar-block,
|
||||||
|
:root[data-theme='dark'] .send-detail-card,
|
||||||
|
:root[data-theme='dark'] .admin-card,
|
||||||
|
:root[data-theme='dark'] .empty {
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: 0 14px 36px rgba(2, 6, 23, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .topbar,
|
||||||
|
:root[data-theme='dark'] .mobile-tabbar,
|
||||||
|
:root[data-theme='dark'] .sort-menu,
|
||||||
|
:root[data-theme='dark'] .create-menu,
|
||||||
|
:root[data-theme='dark'] .dialog-card,
|
||||||
|
:root[data-theme='dark'] .mobile-sidebar-sheet,
|
||||||
|
:root[data-theme='dark'] .mobile-detail-sheet,
|
||||||
|
:root[data-theme='dark'] .mobile-editor-sheet {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .app-side,
|
||||||
|
:root[data-theme='dark'] .sidebar,
|
||||||
|
:root[data-theme='dark'] .mobile-sidebar-sheet .sidebar-block {
|
||||||
|
background: var(--panel-muted);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .brand,
|
||||||
|
:root[data-theme='dark'] .brand-name,
|
||||||
|
:root[data-theme='dark'] .mobile-page-title,
|
||||||
|
:root[data-theme='dark'] .detail-title,
|
||||||
|
:root[data-theme='dark'] .dialog-title,
|
||||||
|
:root[data-theme='dark'] .standalone-title,
|
||||||
|
:root[data-theme='dark'] .standalone-brand-title,
|
||||||
|
:root[data-theme='dark'] .kv-main strong,
|
||||||
|
:root[data-theme='dark'] .list-title,
|
||||||
|
:root[data-theme='dark'] .sidebar-title,
|
||||||
|
:root[data-theme='dark'] .backup-title,
|
||||||
|
:root[data-theme='dark'] .backup-section-title,
|
||||||
|
:root[data-theme='dark'] h1,
|
||||||
|
:root[data-theme='dark'] h2,
|
||||||
|
:root[data-theme='dark'] h3,
|
||||||
|
:root[data-theme='dark'] h4 {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .muted,
|
||||||
|
:root[data-theme='dark'] .detail-sub,
|
||||||
|
:root[data-theme='dark'] .field-help,
|
||||||
|
:root[data-theme='dark'] .list-sub,
|
||||||
|
:root[data-theme='dark'] .kv-label,
|
||||||
|
:root[data-theme='dark'] .standalone-muted,
|
||||||
|
:root[data-theme='dark'] .standalone-footer,
|
||||||
|
:root[data-theme='dark'] .backup-inline-note,
|
||||||
|
:root[data-theme='dark'] .backup-meta,
|
||||||
|
:root[data-theme='dark'] .backup-browser-empty,
|
||||||
|
:root[data-theme='dark'] .dialog-copy,
|
||||||
|
:root[data-theme='dark'] .or,
|
||||||
|
:root[data-theme='dark'] .txt-muted,
|
||||||
|
:root[data-theme='dark'] .mobile-tab,
|
||||||
|
:root[data-theme='dark'] .side-link,
|
||||||
|
:root[data-theme='dark'] .user-chip,
|
||||||
|
:root[data-theme='dark'] .folder-meta,
|
||||||
|
:root[data-theme='dark'] .list-count {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .user-chip {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .side-link:hover,
|
||||||
|
:root[data-theme='dark'] .mobile-tab:hover {
|
||||||
|
background: rgba(105, 167, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .side-link.active,
|
||||||
|
:root[data-theme='dark'] .mobile-tab.active,
|
||||||
|
:root[data-theme='dark'] .sort-menu-item.active,
|
||||||
|
:root[data-theme='dark'] .filter-link.active,
|
||||||
|
:root[data-theme='dark'] .folder-link.active,
|
||||||
|
:root[data-theme='dark'] .list-item.active,
|
||||||
|
:root[data-theme='dark'] .backup-mode-pill.active,
|
||||||
|
:root[data-theme='dark'] .segmented-item.active {
|
||||||
|
background: rgba(105, 167, 255, 0.16);
|
||||||
|
border-color: rgba(105, 167, 255, 0.3);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .input,
|
||||||
|
:root[data-theme='dark'] .textarea,
|
||||||
|
:root[data-theme='dark'] select.input,
|
||||||
|
:root[data-theme='dark'] .dialog input,
|
||||||
|
:root[data-theme='dark'] .dialog textarea,
|
||||||
|
:root[data-theme='dark'] .dialog select {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border-color: #35527a;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .input::placeholder,
|
||||||
|
:root[data-theme='dark'] .textarea::placeholder,
|
||||||
|
:root[data-theme='dark'] input::placeholder,
|
||||||
|
:root[data-theme='dark'] textarea::placeholder {
|
||||||
|
color: #7d8ea8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .input-readonly {
|
||||||
|
background: #101b2b;
|
||||||
|
color: var(--muted-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .input:disabled,
|
||||||
|
:root[data-theme='dark'] .btn:disabled {
|
||||||
|
background: #162338;
|
||||||
|
border-color: #263851;
|
||||||
|
color: #72849f;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .btn-secondary {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border-color: #4b78b8;
|
||||||
|
color: #9ec5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .btn-secondary:hover {
|
||||||
|
background: #173150;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .btn-danger {
|
||||||
|
background: #1d1722;
|
||||||
|
border-color: #d85b78;
|
||||||
|
color: #ff9bb0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .btn-danger:hover {
|
||||||
|
background: #2b1b29;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .btn-primary {
|
||||||
|
color: #08111f;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .toolbar.actions,
|
||||||
|
:root[data-theme='dark'] .list-head,
|
||||||
|
:root[data-theme='dark'] .mobile-panel-head,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-header,
|
||||||
|
:root[data-theme='dark'] .backup-operations-sidebar,
|
||||||
|
:root[data-theme='dark'] .backup-destination-sidebar,
|
||||||
|
:root[data-theme='dark'] .backup-detail-panel,
|
||||||
|
:root[data-theme='dark'] .dialog-actions,
|
||||||
|
:root[data-theme='dark'] .detail-actions,
|
||||||
|
:root[data-theme='dark'] .topbar,
|
||||||
|
:root[data-theme='dark'] .app-side,
|
||||||
|
:root[data-theme='dark'] .kv-row,
|
||||||
|
:root[data-theme='dark'] .attachment-row,
|
||||||
|
:root[data-theme='dark'] .backup-browser-row,
|
||||||
|
:root[data-theme='dark'] .admin-row,
|
||||||
|
:root[data-theme='dark'] .send-row {
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .sidebar,
|
||||||
|
:root[data-theme='dark'] .content,
|
||||||
|
:root[data-theme='dark'] .list-col,
|
||||||
|
:root[data-theme='dark'] .detail-col {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .mobile-sidebar-mask,
|
||||||
|
:root[data-theme='dark'] .dialog-mask,
|
||||||
|
:root[data-theme='dark'] .modal-mask {
|
||||||
|
background: var(--overlay-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .toast {
|
||||||
|
background: #122033;
|
||||||
|
border-color: #253854;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .toast.success {
|
||||||
|
background: #0f2a1f;
|
||||||
|
border-color: #1f5b44;
|
||||||
|
color: #9be2bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .toast.error {
|
||||||
|
background: #2a1720;
|
||||||
|
border-color: #6c2b41;
|
||||||
|
color: #ffb1c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .toast.warning {
|
||||||
|
background: #2d2413;
|
||||||
|
border-color: #7b6230;
|
||||||
|
color: #f7d48b;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .jwt-warning-head,
|
||||||
|
:root[data-theme='dark'] .jwt-warning-label,
|
||||||
|
:root[data-theme='dark'] .jwt-warning-copy,
|
||||||
|
:root[data-theme='dark'] .jwt-warning-list {
|
||||||
|
color: #f4d48a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .theme-switch-input:focus + .theme-switch-slider {
|
||||||
|
box-shadow: 0 0 0 2px rgba(105, 167, 255, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .search-input,
|
||||||
|
:root[data-theme='dark'] .list-head .search-input,
|
||||||
|
:root[data-theme='dark'] .settings-subcard,
|
||||||
|
:root[data-theme='dark'] .backup-operations-sidebar,
|
||||||
|
:root[data-theme='dark'] .backup-destination-sidebar,
|
||||||
|
:root[data-theme='dark'] .backup-detail-panel,
|
||||||
|
:root[data-theme='dark'] .dialog-card,
|
||||||
|
:root[data-theme='dark'] .backup-browser-path,
|
||||||
|
:root[data-theme='dark'] .backup-browser-list,
|
||||||
|
:root[data-theme='dark'] .backup-schedule-current,
|
||||||
|
:root[data-theme='dark'] .backup-status-card,
|
||||||
|
:root[data-theme='dark'] .totp-qr,
|
||||||
|
:root[data-theme='dark'] .create-menu,
|
||||||
|
:root[data-theme='dark'] .create-menu-item,
|
||||||
|
:root[data-theme='dark'] .sort-menu,
|
||||||
|
:root[data-theme='dark'] .sort-menu-item,
|
||||||
|
:root[data-theme='dark'] .import-export-feature-item,
|
||||||
|
:root[data-theme='dark'] .import-export-feature-icon,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-card,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-dav-item,
|
||||||
|
:root[data-theme='dark'] .backup-destination-item,
|
||||||
|
:root[data-theme='dark'] .totp-code-row,
|
||||||
|
:root[data-theme='dark'] .list-item {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item:hover,
|
||||||
|
:root[data-theme='dark'] .sort-menu-item:hover,
|
||||||
|
:root[data-theme='dark'] .create-menu-item:hover,
|
||||||
|
:root[data-theme='dark'] .backup-destination-item:hover,
|
||||||
|
:root[data-theme='dark'] .import-export-feature-item:hover {
|
||||||
|
background: #13243a;
|
||||||
|
border-color: #2e4665;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item.active {
|
||||||
|
background: linear-gradient(180deg, #173150, #1a385b);
|
||||||
|
border-color: #38618f;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(158, 197, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-destination-item.active,
|
||||||
|
:root[data-theme='dark'] .backup-interval-preset.active,
|
||||||
|
:root[data-theme='dark'] .tree-btn.active {
|
||||||
|
background: #1d4f99;
|
||||||
|
border-color: #4d86d7;
|
||||||
|
color: #f4f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .totp-code-name,
|
||||||
|
:root[data-theme='dark'] .backup-destination-name,
|
||||||
|
:root[data-theme='dark'] .backup-browser-entry,
|
||||||
|
:root[data-theme='dark'] .backup-browser-path strong,
|
||||||
|
:root[data-theme='dark'] .backup-status-grid strong,
|
||||||
|
:root[data-theme='dark'] .backup-option-label,
|
||||||
|
:root[data-theme='dark'] .sort-menu-item,
|
||||||
|
:root[data-theme='dark'] .create-menu-item,
|
||||||
|
:root[data-theme='dark'] .tree-btn,
|
||||||
|
:root[data-theme='dark'] .folder-add-btn,
|
||||||
|
:root[data-theme='dark'] .list-icon-fallback,
|
||||||
|
:root[data-theme='dark'] .totp-code-main strong,
|
||||||
|
:root[data-theme='dark'] .totp-timer-value {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .totp-code-username,
|
||||||
|
:root[data-theme='dark'] .backup-destination-meta,
|
||||||
|
:root[data-theme='dark'] .backup-browser-meta,
|
||||||
|
:root[data-theme='dark'] .backup-file-meta,
|
||||||
|
:root[data-theme='dark'] .backup-list,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-step,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-inline-note,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-linked-item,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-referral,
|
||||||
|
:root[data-theme='dark'] .backup-retention-suffix,
|
||||||
|
:root[data-theme='dark'] .backup-inline-suffix,
|
||||||
|
:root[data-theme='dark'] .folder-delete-btn,
|
||||||
|
:root[data-theme='dark'] .folder-add-btn:hover,
|
||||||
|
:root[data-theme='dark'] .tree-label,
|
||||||
|
:root[data-theme='dark'] .list-sub {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .import-export-feature-item p,
|
||||||
|
:root[data-theme='dark'] .import-export-hero-sub,
|
||||||
|
:root[data-theme='dark'] .import-export-panel p,
|
||||||
|
:root[data-theme='dark'] .dialog-message,
|
||||||
|
:root[data-theme='dark'] .local-error,
|
||||||
|
:root[data-theme='dark'] .status-ok {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-destination-type {
|
||||||
|
background: #1d3048;
|
||||||
|
color: #c9d8eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-help-trigger {
|
||||||
|
border-color: #38618f;
|
||||||
|
background: #173150;
|
||||||
|
color: #9ec5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-help-trigger:hover,
|
||||||
|
:root[data-theme='dark'] .backup-help-trigger:focus-visible {
|
||||||
|
border-color: #5f92d7;
|
||||||
|
background: #20426a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-help-bubble {
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-help-bubble::before {
|
||||||
|
background: var(--panel);
|
||||||
|
border-left-color: var(--line);
|
||||||
|
border-top-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .dialog-icon {
|
||||||
|
color: #f7d48b;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .local-error {
|
||||||
|
color: #ff9bb0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .status-ok {
|
||||||
|
color: #9be2bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .totp-qr svg,
|
||||||
|
:root[data-theme='dark'] .totp-qr img {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user