feat: system theme option + fix toast timer overlap
Lint & Test / test (push) Successful in 1m27s

Add third theme mode (system) that follows OS prefers-color-scheme.
Theme button cycles dark → light → system with monitor icon.
Listens for OS preference changes in real time when in system mode.

Fix showToast clearing previous timer so rapid calls don't cause
the toast to disappear early.
This commit is contained in:
2026-03-30 13:55:38 +03:00
parent 4b7a8d75f4
commit db5008aaeb
5 changed files with 174 additions and 149 deletions
@@ -115,12 +115,16 @@ export function closeLightbox(event?: Event) {
unlockBody();
}
let _toastTimer: ReturnType<typeof setTimeout> | null = null;
export function showToast(message: string, type = 'info') {
if (_toastTimer) clearTimeout(_toastTimer);
const toast = document.getElementById('toast')!;
toast.textContent = message;
toast.className = `toast ${type} show`;
setTimeout(() => {
_toastTimer = setTimeout(() => {
toast.className = 'toast';
_toastTimer = null;
}, 3000);
}
@@ -1765,6 +1765,7 @@
"stream.error.clone_pp_failed": "Failed to clone postprocessing template",
"theme.switched.dark": "Switched to dark theme",
"theme.switched.light": "Switched to light theme",
"theme.switched.system": "Switched to system theme",
"accent.color.updated": "Accent color updated",
"search.footer": "↑↓ navigate · Enter select · Esc close",
"sync_clock.group.title": "Sync Clocks",
@@ -743,71 +743,68 @@
"automations.name.placeholder": "Моя автоматизация",
"automations.enabled": "Включена:",
"automations.enabled.hint": "Отключённые автоматизации не активируются даже при выполнении условий",
"automations.condition_logic": "Логика условий:",
"automations.condition_logic.hint": "Как объединяются несколько условий: ЛЮБОЕ (ИЛИ) или ВСЕ (И)",
"automations.condition_logic.or": "Любое условие (ИЛИ)",
"automations.condition_logic.and": "Все условия (И)",
"automations.condition_logic.or.desc": "Срабатывает при любом совпадении",
"automations.condition_logic.and.desc": "Срабатывает только при всех",
"automations.conditions": "Условия:",
"automations.conditions.hint": "Правила, определяющие когда автоматизация активируется",
"automations.conditions.add": "Добавить условие",
"automations.conditions.empty": "Нет условий — автоматизация всегда активна когда включена",
"automations.condition.always": "Всегда",
"automations.condition.always.desc": "Всегда активно",
"automations.condition.always.hint": "Автоматизация активируется сразу при включении и остаётся активной.",
"automations.condition.startup": "Автозапуск",
"automations.condition.startup.desc": "При запуске сервера",
"automations.condition.startup.hint": "Активируется при запуске сервера и остаётся активной пока включена.",
"automations.condition.application": "Приложение",
"automations.condition.application.desc": "Приложение запущено",
"automations.condition.application.apps": "Приложения:",
"automations.condition.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)",
"automations.condition.application.browse": "Обзор",
"automations.condition.application.search": "Фильтр процессов...",
"automations.condition.application.no_processes": "Процессы не найдены",
"automations.condition.application.match_type": "Тип соответствия:",
"automations.condition.application.match_type.hint": "Как определять наличие приложения",
"automations.condition.application.match_type.running": "Запущено",
"automations.condition.application.match_type.running.desc": "Процесс активен",
"automations.condition.application.match_type.topmost": "На переднем плане",
"automations.condition.application.match_type.topmost.desc": "Окно в фокусе",
"automations.condition.application.match_type.topmost_fullscreen": "Передний план + ПЭ",
"automations.condition.application.match_type.topmost_fullscreen.desc": "В фокусе + полный экран",
"automations.condition.application.match_type.fullscreen": "Полный экран",
"automations.condition.application.match_type.fullscreen.desc": "Любое полноэкранное",
"automations.condition.time_of_day": "Время суток",
"automations.condition.time_of_day.desc": "Диапазон времени",
"automations.condition.time_of_day.start_time": "Время начала:",
"automations.condition.time_of_day.end_time": "Время окончания:",
"automations.condition.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.",
"automations.condition.system_idle": "Бездействие системы",
"automations.condition.system_idle.desc": "Бездействие/активность",
"automations.condition.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):",
"automations.condition.system_idle.mode": "Режим срабатывания:",
"automations.condition.system_idle.when_idle": "При бездействии",
"automations.condition.system_idle.when_active": "При активности",
"automations.condition.display_state": "Состояние дисплея",
"automations.condition.display_state.desc": "Монитор вкл/выкл",
"automations.condition.display_state.state": "Состояние монитора:",
"automations.condition.display_state.on": "Включён",
"automations.condition.display_state.off": "Выключен (спящий режим)",
"automations.condition.mqtt": "MQTT",
"automations.condition.mqtt.desc": "MQTT сообщение",
"automations.condition.mqtt.topic": "Топик:",
"automations.condition.mqtt.payload": "Значение:",
"automations.condition.mqtt.match_mode": "Режим сравнения:",
"automations.condition.mqtt.match_mode.exact": "Точное совпадение",
"automations.condition.mqtt.match_mode.contains": "Содержит",
"automations.condition.mqtt.match_mode.regex": "Регулярное выражение",
"automations.condition.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику",
"automations.condition.webhook": "Вебхук",
"automations.condition.webhook.desc": "HTTP вызов",
"automations.condition.webhook.hint": "Активировать через HTTP-запрос от внешних сервисов (Home Assistant, IFTTT, curl и т.д.)",
"automations.condition.webhook.url": "URL вебхука:",
"automations.condition.webhook.copy": "Скопировать",
"automations.condition.webhook.copied": "Скопировано!",
"automations.condition.webhook.save_first": "Сначала сохраните автоматизацию для генерации URL вебхука",
"automations.rule_logic": "Логика условий:",
"automations.rule_logic.hint": "Как объединяются несколько условий: ЛЮБОЕ (ИЛИ) или ВСЕ (И)",
"automations.rule_logic.or": "Любое условие (ИЛИ)",
"automations.rule_logic.and": "Все условия (И)",
"automations.rule_logic.or.desc": "Срабатывает при любом совпадении",
"automations.rule_logic.and.desc": "Срабатывает только при всех",
"automations.rules": "Условия:",
"automations.rules.hint": "Правила, определяющие когда автоматизация активируется",
"automations.rules.add": "Добавить условие",
"automations.rules.empty": "Нет условий — автоматизация всегда активна когда включена",
"automations.rule.startup": "Автозапуск",
"automations.rule.startup.desc": "При запуске сервера",
"automations.rule.startup.hint": "Активируется при запуске сервера и остаётся активной пока включена.",
"automations.rule.application": "Приложение",
"automations.rule.application.desc": "Приложение запущено",
"automations.rule.application.apps": "Приложения:",
"automations.rule.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)",
"automations.rule.application.browse": "Обзор",
"automations.rule.application.search": "Фильтр процессов...",
"automations.rule.application.no_processes": "Процессы не найдены",
"automations.rule.application.match_type": "Тип соответствия:",
"automations.rule.application.match_type.hint": "Как определять наличие приложения",
"automations.rule.application.match_type.running": "Запущено",
"automations.rule.application.match_type.running.desc": "Процесс активен",
"automations.rule.application.match_type.topmost": "На переднем плане",
"automations.rule.application.match_type.topmost.desc": "Окно в фокусе",
"automations.rule.application.match_type.topmost_fullscreen": "Передний план + ПЭ",
"automations.rule.application.match_type.topmost_fullscreen.desc": "В фокусе + полный экран",
"automations.rule.application.match_type.fullscreen": "Полный экран",
"automations.rule.application.match_type.fullscreen.desc": "Любое полноэкранное",
"automations.rule.time_of_day": "Время суток",
"automations.rule.time_of_day.desc": "Диапазон времени",
"automations.rule.time_of_day.start_time": "Время начала:",
"automations.rule.time_of_day.end_time": "Время окончания:",
"automations.rule.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.",
"automations.rule.system_idle": "Бездействие системы",
"automations.rule.system_idle.desc": "Бездействие/активность",
"automations.rule.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):",
"automations.rule.system_idle.mode": "Режим срабатывания:",
"automations.rule.system_idle.when_idle": "При бездействии",
"automations.rule.system_idle.when_active": "При активности",
"automations.rule.display_state": "Состояние дисплея",
"automations.rule.display_state.desc": "Монитор вкл/выкл",
"automations.rule.display_state.state": "Состояние монитора:",
"automations.rule.display_state.on": "Включён",
"automations.rule.display_state.off": "Выключен (спящий режим)",
"automations.rule.mqtt": "MQTT",
"automations.rule.mqtt.desc": "MQTT сообщение",
"automations.rule.mqtt.topic": "Топик:",
"automations.rule.mqtt.payload": "Значение:",
"automations.rule.mqtt.match_mode": "Режим сравнения:",
"automations.rule.mqtt.match_mode.exact": "Точное совпадение",
"automations.rule.mqtt.match_mode.contains": "Содержит",
"automations.rule.mqtt.match_mode.regex": "Регулярное выражение",
"automations.rule.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику",
"automations.rule.webhook": "Вебхук",
"automations.rule.webhook.desc": "HTTP вызов",
"automations.rule.webhook.hint": "Активировать через HTTP-запрос от внешних сервисов (Home Assistant, IFTTT, curl и т.д.)",
"automations.rule.webhook.url": "URL вебхука:",
"automations.rule.webhook.copy": "Скопировать",
"automations.rule.webhook.copied": "Скопировано!",
"automations.rule.webhook.save_first": "Сначала сохраните автоматизацию для генерации URL вебхука",
"automations.scene": "Сцена:",
"automations.scene.hint": "Пресет сцены для активации при выполнении условий",
"automations.scene.search_placeholder": "Поиск сцен...",
@@ -1626,6 +1623,7 @@
"stream.error.clone_pp_failed": "Не удалось клонировать шаблон постобработки",
"theme.switched.dark": "Переключено на тёмную тему",
"theme.switched.light": "Переключено на светлую тему",
"theme.switched.system": "Переключено на системную тему",
"accent.color.updated": "Цвет акцента обновлён",
"search.footer": "↑↓ навигация · Enter выбор · Esc закрыть",
"sync_clock.group.title": "Часы синхронизации",
@@ -743,71 +743,68 @@
"automations.name.placeholder": "我的自动化",
"automations.enabled": "启用:",
"automations.enabled.hint": "禁用的自动化即使满足条件也不会激活",
"automations.condition_logic": "条件逻辑:",
"automations.condition_logic.hint": "多个条件的组合方式:任一(或)或 全部(与)",
"automations.condition_logic.or": "任一条件(或)",
"automations.condition_logic.and": "全部条件(与)",
"automations.condition_logic.or.desc": "任一条件匹配时触发",
"automations.condition_logic.and.desc": "全部匹配时才触发",
"automations.conditions": "条件:",
"automations.conditions.hint": "决定此自动化何时激活的规则",
"automations.conditions.add": "添加条件",
"automations.conditions.empty": "无条件 — 启用后自动化始终处于活动状态",
"automations.condition.always": "始终",
"automations.condition.always.desc": "始终活跃",
"automations.condition.always.hint": "自动化启用后立即激活并保持活动。",
"automations.condition.startup": "启动",
"automations.condition.startup.desc": "服务器启动时",
"automations.condition.startup.hint": "服务器启动时激活,启用期间保持活动。",
"automations.condition.application": "应用程序",
"automations.condition.application.desc": "应用运行/聚焦",
"automations.condition.application.apps": "应用程序:",
"automations.condition.application.apps.hint": "进程名,每行一个(例如 firefox.exe",
"automations.condition.application.browse": "浏览",
"automations.condition.application.search": "筛选进程...",
"automations.condition.application.no_processes": "未找到进程",
"automations.condition.application.match_type": "匹配类型:",
"automations.condition.application.match_type.hint": "如何检测应用程序",
"automations.condition.application.match_type.running": "运行中",
"automations.condition.application.match_type.running.desc": "进程活跃",
"automations.condition.application.match_type.topmost": "前",
"automations.condition.application.match_type.topmost.desc": "前台窗口",
"automations.condition.application.match_type.topmost_fullscreen": "最前 + 全屏",
"automations.condition.application.match_type.topmost_fullscreen.desc": "前台 + 全屏",
"automations.condition.application.match_type.fullscreen": "全屏",
"automations.condition.application.match_type.fullscreen.desc": "任意全屏应用",
"automations.condition.time_of_day": "时段",
"automations.condition.time_of_day.desc": "时间范围",
"automations.condition.time_of_day.start_time": "开始时间:",
"automations.condition.time_of_day.end_time": "结束时间:",
"automations.condition.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。",
"automations.condition.system_idle": "系统空闲",
"automations.condition.system_idle.desc": "空闲/活跃",
"automations.condition.system_idle.idle_minutes": "空闲超时(分钟):",
"automations.condition.system_idle.mode": "触发模式:",
"automations.condition.system_idle.when_idle": "空闲时",
"automations.condition.system_idle.when_active": "活跃时",
"automations.condition.display_state": "显示器状态",
"automations.condition.display_state.desc": "显示器开/关",
"automations.condition.display_state.state": "显示器状态:",
"automations.condition.display_state.on": "开启",
"automations.condition.display_state.off": "关闭(休眠)",
"automations.condition.mqtt": "MQTT",
"automations.condition.mqtt.desc": "MQTT 消息",
"automations.condition.mqtt.topic": "主题:",
"automations.condition.mqtt.payload": "消息内容:",
"automations.condition.mqtt.match_mode": "匹配模式:",
"automations.condition.mqtt.match_mode.exact": "精确匹配",
"automations.condition.mqtt.match_mode.contains": "包含",
"automations.condition.mqtt.match_mode.regex": "正则表达式",
"automations.condition.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活",
"automations.condition.webhook": "Webhook",
"automations.condition.webhook.desc": "HTTP 回调",
"automations.condition.webhook.hint": "通过外部服务的 HTTP 请求激活(Home Assistant、IFTTT、curl 等)",
"automations.condition.webhook.url": "Webhook URL",
"automations.condition.webhook.copy": "复制",
"automations.condition.webhook.copied": "已复制!",
"automations.condition.webhook.save_first": "请先保存自动化以生成 Webhook URL",
"automations.rule_logic": "条件逻辑:",
"automations.rule_logic.hint": "多个条件的组合方式:任一(或)或 全部(与)",
"automations.rule_logic.or": "任一条件(或)",
"automations.rule_logic.and": "全部条件(与)",
"automations.rule_logic.or.desc": "任一条件匹配时触发",
"automations.rule_logic.and.desc": "全部匹配时才触发",
"automations.rules": "条件:",
"automations.rules.hint": "决定此自动化何时激活的规则",
"automations.rules.add": "添加条件",
"automations.rules.empty": "无条件 — 启用后自动化始终处于活动状态",
"automations.rule.startup": "启动",
"automations.rule.startup.desc": "服务器启动时",
"automations.rule.startup.hint": "服务器启动时激活,启用期间保持活动。",
"automations.rule.application": "应用程序",
"automations.rule.application.desc": "应用运行/聚焦",
"automations.rule.application.apps": "应用程序:",
"automations.rule.application.apps.hint": "进程名,每行一个(例如 firefox.exe",
"automations.rule.application.browse": "浏览",
"automations.rule.application.search": "筛选进程...",
"automations.rule.application.no_processes": "未找到进程",
"automations.rule.application.match_type": "匹配类型:",
"automations.rule.application.match_type.hint": "如何检测应用程序",
"automations.rule.application.match_type.running": "运行中",
"automations.rule.application.match_type.running.desc": "进程活跃",
"automations.rule.application.match_type.topmost": "最前",
"automations.rule.application.match_type.topmost.desc": "前台窗口",
"automations.rule.application.match_type.topmost_fullscreen": "最前 + 全屏",
"automations.rule.application.match_type.topmost_fullscreen.desc": "前台 + 全屏",
"automations.rule.application.match_type.fullscreen": "全屏",
"automations.rule.application.match_type.fullscreen.desc": "任意全屏应用",
"automations.rule.time_of_day": "时段",
"automations.rule.time_of_day.desc": "时间范围",
"automations.rule.time_of_day.start_time": "开始时间:",
"automations.rule.time_of_day.end_time": "结束时间:",
"automations.rule.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。",
"automations.rule.system_idle": "系统空闲",
"automations.rule.system_idle.desc": "空闲/活跃",
"automations.rule.system_idle.idle_minutes": "空闲超时(分钟):",
"automations.rule.system_idle.mode": "触发模式:",
"automations.rule.system_idle.when_idle": "空闲",
"automations.rule.system_idle.when_active": "活跃时",
"automations.rule.display_state": "显示器状态",
"automations.rule.display_state.desc": "显示器开/关",
"automations.rule.display_state.state": "显示器状态:",
"automations.rule.display_state.on": "开启",
"automations.rule.display_state.off": "关闭(休眠)",
"automations.rule.mqtt": "MQTT",
"automations.rule.mqtt.desc": "MQTT 消息",
"automations.rule.mqtt.topic": "主题:",
"automations.rule.mqtt.payload": "消息内容:",
"automations.rule.mqtt.match_mode": "匹配模式:",
"automations.rule.mqtt.match_mode.exact": "精确匹配",
"automations.rule.mqtt.match_mode.contains": "包含",
"automations.rule.mqtt.match_mode.regex": "正则表达式",
"automations.rule.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活",
"automations.rule.webhook": "Webhook",
"automations.rule.webhook.desc": "HTTP 回调",
"automations.rule.webhook.hint": "通过外部服务的 HTTP 请求激活(Home Assistant、IFTTT、curl 等)",
"automations.rule.webhook.url": "Webhook URL",
"automations.rule.webhook.copy": "复制",
"automations.rule.webhook.copied": "已复制!",
"automations.rule.webhook.save_first": "请先保存自动化以生成 Webhook URL",
"automations.scene": "场景:",
"automations.scene.hint": "条件满足时激活的场景预设",
"automations.scene.search_placeholder": "搜索场景...",
@@ -1626,6 +1623,7 @@
"stream.error.clone_pp_failed": "克隆后处理模板失败",
"theme.switched.dark": "已切换到深色主题",
"theme.switched.light": "已切换到浅色主题",
"theme.switched.system": "已切换到系统主题",
"accent.color.updated": "强调色已更新",
"search.footer": "↑↓ 导航 · Enter 选择 · Esc 关闭",
"sync_clock.group.title": "同步时钟",
+42 -18
View File
@@ -279,30 +279,54 @@
if (btn) btn.style.opacity = state === 'on' ? '1' : '0.5';
}
// Initialize theme
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
updateThemeIcon(savedTheme);
// Initialize theme (preference can be 'dark', 'light', or 'system')
const _systemDarkMq = window.matchMedia('(prefers-color-scheme: dark)');
function updateThemeIcon(theme) {
function _resolveTheme(pref) {
if (pref === 'system') return _systemDarkMq.matches ? 'dark' : 'light';
return pref;
}
function _applyTheme(resolved) {
document.documentElement.setAttribute('data-theme', resolved);
if (window._updateBgAnimTheme) window._updateBgAnimTheme(resolved === 'dark');
const accent = localStorage.getItem('accentColor');
if (accent) applyAccentColor(accent, true);
}
const _themePref = localStorage.getItem('theme') || 'dark';
_applyTheme(_resolveTheme(_themePref));
updateThemeIcon(_themePref);
// Listen for OS preference changes when in system mode
_systemDarkMq.addEventListener('change', function() {
if (localStorage.getItem('theme') === 'system') {
_applyTheme(_resolveTheme('system'));
}
});
function updateThemeIcon(pref) {
const icon = document.getElementById('theme-icon');
icon.innerHTML = theme === 'dark'
? '<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>'
: '<svg class="icon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></svg>';
if (pref === 'system') {
icon.innerHTML = '<svg class="icon" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>';
} else if (pref === 'dark') {
icon.innerHTML = '<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>';
} else {
icon.innerHTML = '<svg class="icon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></svg>';
}
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
const current = localStorage.getItem('theme') || 'dark';
const order = ['dark', 'light', 'system'];
const next = order[(order.indexOf(current) + 1) % order.length];
const resolved = _resolveTheme(next);
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme);
if (window._updateBgAnimTheme) window._updateBgAnimTheme(newTheme === 'dark');
// Re-derive accent text variant for the new theme
const accent = localStorage.getItem('accentColor');
if (accent) applyAccentColor(accent, true);
showToast(window.t ? t(newTheme === 'dark' ? 'theme.switched.dark' : 'theme.switched.light') : `Switched to ${newTheme} theme`, 'info');
localStorage.setItem('theme', next);
_applyTheme(resolved);
updateThemeIcon(next);
const toastKeys = { dark: 'theme.switched.dark', light: 'theme.switched.light', system: 'theme.switched.system' };
showToast(window.t ? t(toastKeys[next]) : `Switched to ${next} theme`, 'info');
}
// Initialize accent color