diff --git a/project_docs/THEME_SELECTION_V1_DESIGN.md b/project_docs/THEME_SELECTION_V1_DESIGN.md new file mode 100644 index 0000000..be5635b --- /dev/null +++ b/project_docs/THEME_SELECTION_V1_DESIGN.md @@ -0,0 +1,177 @@ +# Theme Selection v1 + +## 1. Doel +Theme-selectie voegt nu waarde toe omdat de UI al een nette light/dark basis heeft, maar nog geen expliciet onderscheid maakt tussen: +- `theme`: de stijlset +- `mode`: light of dark binnen die stijlset + +Dat onderscheid maakt de UI uitbreidbaar zonder de dagelijkse snelle UX kwijt te raken. Dit past logisch in de bestaande Settings-structuur: +- `General` voor functionele voorkeuren +- `Interface` voor theme-keuze +- `Logs` voor recente acties + +## 2. Scope +Theme Selection v1 omvat: +- nieuw Settings-tabblad: `Interface` +- daarin alleen een pulldown/select: `Theme` +- bestaande snelle dark/light toggle blijft in de hoofdinterface bestaan +- beide keuzes worden opgeslagen in bestaande SQLite settings-opslag +- app leest beide waarden bij startup via backend en past die direct toe + +Niet in scope: +- vrije CSS-bestandskeuze +- padinvoer +- upload van themes +- custom theme editor +- theme packs van externe bron + +## 3. Theme-model +Aanbevolen model voor v1: +- werk met een whitelist van toegestane theme keys +- werk daarnaast met een aparte whitelist van toegestane color modes +- sla beide als strings op in settings + +Aanbevolen settings voor v1: +- `selected_theme: string | null` +- `selected_color_mode: string | null` + +Whitelist v1: +- `selected_theme` + - `default` +- `selected_color_mode` + - `dark` + - `light` + +Waarom dit veiliger en eenvoudiger is dan bestandsselectie: +- geen vrije filesystemtoegang nodig +- geen risico op ongeldige of kwaadaardige CSS-inhoud +- geen extra upload- of assetbeheer +- duidelijke validatie in backend mogelijk +- stabiel contract tussen backend setting en frontend rendering + +## 4. Settings-opslag +Nieuwe settings in bestaande settings-opslag: +- `selected_theme` +- `selected_color_mode` + +Semantiek: +- `selected_theme = null` betekent: fallback naar veilige default `default` +- `selected_color_mode = null` betekent: fallback naar veilige default `dark` +- onbekende opgeslagen waarden betekenen: negeren en fallback toepassen + +Aanbevolen effectieve defaults: +- theme -> `default` +- mode -> `dark` + +## 5. Settings UI +Tabs in Settings worden: +- `General` +- `Interface` +- `Logs` + +`Interface` bevat in v1 alleen: +- label: `Theme` +- een select/pulldown met toegestane themes + +Belangrijk: +- geen dark/light selector in `Settings > Interface` +- dark/light blijft een snelle hoofdinterface-actie + +Aanbevolen v1-UX: +- select toont huidige theme-keuze +- gebruiker kiest andere waarde +- opslaan gebeurt via bestaande settings-saveflow +- keuze wordt direct toegepast in de UI na succesvolle backend-save + +## 6. Frontend-impact +Frontend moet bij startup vroeg settings laden en daaruit beide waarden ophalen: +- `selected_theme` +- `selected_color_mode` + +Daarna bepaalt frontend het effectieve UI-theme. Aanbevolen intern model: +- `data-theme="default-dark"` +- `data-theme="default-light"` + +Aanbevolen volgorde: +1. `GET /api/settings` +2. bepaal effectief theme + mode +3. zet `document.documentElement.dataset.theme` +4. initialiseer de rest van de UI + +Relatie met bestaande light/dark toggle: +- toggle blijft bestaan in de hoofdinterface +- toggle wijzigt alleen `selected_color_mode` +- toggle schrijft dus naar backend, niet naar localStorage + +Reden: +- snelle dagelijkse UX blijft behouden +- `Settings > Interface` blijft schoon en beperkt tot theme-keuze +- theme en mode blijven conceptueel gescheiden + +## 7. Backend-impact +Bestaande settings-API wordt uitgebreid met: +- `selected_theme` +- `selected_color_mode` + +Benodigd: +- whitelistvalidatie op backend +- onbekende waarden blokkeren bij write +- bestaande settings repository/service/API uitbreiden + +Niet nodig: +- nieuwe dependency +- vrije filesystemtoegang +- nieuwe asset-uploadroute + +## 8. Regressierisico +Belangrijkste risico's: +- startup-volgorde: theme moet vroeg genoeg worden toegepast om flicker te beperken +- bestaande theme-toggle logica conflicteert nu nog met localStorage +- onbekende opgeslagen theme/mode-waarden moeten veilig terugvallen +- Settings-tabcomplexiteit mag niet onnodig toenemen + +Belangrijkste mitigaties: +- één centrale frontendfunctie die theme en mode uit backend toepast +- localStorage volledig verwijderen als leidende theme-bron +- backend whitelistvalidatie +- fallback naar `default-dark` + +## 9. Teststrategie +Backend golden tests: +- default `selected_theme` +- default `selected_color_mode` +- geldige theme save (`default`) +- geldige color mode save (`dark`, `light`) +- ongeldige theme key wordt geblokkeerd +- ongeldige color mode wordt geblokkeerd +- settings response bevat beide velden + +UI smoke/regressietests: +- `Settings` bevat tabs `General`, `Interface`, `Logs` +- `Interface` tab bevat alleen theme select +- hoofdinterface bevat nog steeds dark/light toggle +- app leest beide settings via backend +- fallback bij ontbrekende/ongeldige waarde breekt startup niet + +Handmatige validatie: +- theme wijzigen in `Settings > Interface` +- mode wisselen via toggle in de hoofdinterface +- app herladen en controleren dat beide keuzes behouden blijven +- controle dat light/dark correct doorwerken in modals, panelen en editor/viewers + +## 10. Aanbeveling +Aanbevolen v1-richting met laag regressierisico: +- voeg `Interface` tab toe +- voeg `selected_theme` en `selected_color_mode` toe aan bestaande settings-opslag +- werk alleen met veilige whitelists +- houd v1 beperkt tot: + - theme: `default` + - mode: `dark|light` +- laat startup en toggle beide backendpersistente settings gebruiken +- fallback altijd veilig naar `default-dark` + +Deze richting is: +- simpel +- veilig +- onderhoudbaar +- duidelijk uitbreidbaar naar extra themes, zonder de dagelijkse dark/light UX opnieuw te moeten ontwerpen diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index 5ee8970..a3c9719 100644 Binary files a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc and b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc differ diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index e3505da..1a7b639 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -98,12 +98,16 @@ class SettingsResponse(BaseModel): show_thumbnails: bool preferred_startup_path_left: str | None = None preferred_startup_path_right: str | None = None + selected_theme: str + selected_color_mode: str class SettingsUpdateRequest(BaseModel): show_thumbnails: bool | None = None preferred_startup_path_left: str | None = None preferred_startup_path_right: str | None = None + selected_theme: str | None = None + selected_color_mode: str | None = None class TaskListItem(BaseModel): diff --git a/webui/backend/app/services/__pycache__/settings_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/settings_service.cpython-313.pyc index 0b9080b..2fbc306 100644 Binary files a/webui/backend/app/services/__pycache__/settings_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/settings_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/settings_service.py b/webui/backend/app/services/settings_service.py index cbf8705..950f602 100644 --- a/webui/backend/app/services/settings_service.py +++ b/webui/backend/app/services/settings_service.py @@ -1,10 +1,15 @@ from __future__ import annotations +from backend.app.api.errors import AppError from backend.app.api.schemas import SettingsResponse, SettingsUpdateRequest from backend.app.db.settings_repository import SettingsRepository from backend.app.security.path_guard import PathGuard +VALID_THEMES = {"default"} +VALID_COLOR_MODES = {"dark", "light"} + + class SettingsService: def __init__(self, repository: SettingsRepository, path_guard: PathGuard): self._repository = repository @@ -15,10 +20,14 @@ class SettingsService: preferred_left = self._as_optional_str(values.get("preferred_startup_path_left")) preferred_right = self._as_optional_str(values.get("preferred_startup_path_right")) legacy_preferred = self._as_optional_str(values.get("preferred_startup_path")) + selected_theme = self._normalize_theme(values.get("selected_theme")) + selected_color_mode = self._normalize_color_mode(values.get("selected_color_mode")) return SettingsResponse( show_thumbnails=self._as_bool(values.get("show_thumbnails"), default=False), preferred_startup_path_left=preferred_left or legacy_preferred, preferred_startup_path_right=preferred_right, + selected_theme=selected_theme, + selected_color_mode=selected_color_mode, ) def update_settings(self, request: SettingsUpdateRequest) -> SettingsResponse: @@ -31,6 +40,12 @@ class SettingsService: if request.preferred_startup_path_right is not None: self._set_directory_setting("preferred_startup_path_right", request.preferred_startup_path_right) + if request.selected_theme is not None: + self._repository.set_setting("selected_theme", self._validate_theme(request.selected_theme)) + + if request.selected_color_mode is not None: + self._repository.set_setting("selected_color_mode", self._validate_color_mode(request.selected_color_mode)) + return self.get_settings() def _set_directory_setting(self, key: str, value: str) -> None: @@ -53,3 +68,35 @@ class SettingsService: return None normalized = value.strip() return normalized or None + + @staticmethod + def _normalize_theme(value: str | None) -> str: + normalized = (value or "").strip() + return normalized if normalized in VALID_THEMES else "default" + + @staticmethod + def _normalize_color_mode(value: str | None) -> str: + normalized = (value or "").strip().lower() + return normalized if normalized in VALID_COLOR_MODES else "dark" + + @staticmethod + def _validate_theme(value: str) -> str: + normalized = value.strip() + if normalized not in VALID_THEMES: + raise AppError( + status_code=400, + code="invalid_request", + message="Theme must be one of: default", + ) + return normalized + + @staticmethod + def _validate_color_mode(value: str) -> str: + normalized = value.strip().lower() + if normalized not in VALID_COLOR_MODES: + raise AppError( + status_code=400, + code="invalid_request", + message="Color mode must be one of: dark, light", + ) + return normalized diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 5a841db..0828ee2 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_api_settings_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_settings_golden.cpython-313.pyc index d900cdf..1b6dd31 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_settings_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_settings_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index f76844d..8b64308 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_api_settings_golden.py b/webui/backend/tests/golden/test_api_settings_golden.py index bec6dd7..b385d05 100644 --- a/webui/backend/tests/golden/test_api_settings_golden.py +++ b/webui/backend/tests/golden/test_api_settings_golden.py @@ -59,6 +59,8 @@ class SettingsApiGoldenTest(unittest.TestCase): "show_thumbnails": False, "preferred_startup_path_left": None, "preferred_startup_path_right": None, + "selected_theme": "default", + "selected_color_mode": "dark", }, ) @@ -75,6 +77,8 @@ class SettingsApiGoldenTest(unittest.TestCase): "show_thumbnails": False, "preferred_startup_path_left": "storage1/docs", "preferred_startup_path_right": None, + "selected_theme": "default", + "selected_color_mode": "dark", }, ) @@ -96,6 +100,8 @@ class SettingsApiGoldenTest(unittest.TestCase): "show_thumbnails": True, "preferred_startup_path_left": "storage1/docs", "preferred_startup_path_right": "storage1/docs", + "selected_theme": "default", + "selected_color_mode": "dark", }, ) self.assertEqual( @@ -104,6 +110,8 @@ class SettingsApiGoldenTest(unittest.TestCase): "show_thumbnails": True, "preferred_startup_path_left": "storage1/docs", "preferred_startup_path_right": "storage1/docs", + "selected_theme": "default", + "selected_color_mode": "dark", }, ) @@ -113,6 +121,8 @@ class SettingsApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["preferred_startup_path_left"], "storage1/docs") self.assertEqual(response.json()["preferred_startup_path_right"], None) + self.assertEqual(response.json()["selected_theme"], "default") + self.assertEqual(response.json()["selected_color_mode"], "dark") def test_settings_preferred_startup_path_right_persistence(self) -> None: response = self._request("POST", "/api/settings", {"preferred_startup_path_right": "storage1/docs"}) @@ -120,6 +130,8 @@ class SettingsApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["preferred_startup_path_left"], None) self.assertEqual(response.json()["preferred_startup_path_right"], "storage1/docs") + self.assertEqual(response.json()["selected_theme"], "default") + self.assertEqual(response.json()["selected_color_mode"], "dark") def test_settings_preferred_startup_path_empty_string_resets_only_left_to_null(self) -> None: self._request( @@ -135,6 +147,34 @@ class SettingsApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["preferred_startup_path_left"], None) self.assertEqual(response.json()["preferred_startup_path_right"], "storage1/docs") + self.assertEqual(response.json()["selected_theme"], "default") + self.assertEqual(response.json()["selected_color_mode"], "dark") + + def test_settings_selected_theme_persistence(self) -> None: + response = self._request("POST", "/api/settings", {"selected_theme": "default"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["selected_theme"], "default") + self.assertEqual(response.json()["selected_color_mode"], "dark") + + def test_settings_selected_color_mode_persistence(self) -> None: + response = self._request("POST", "/api/settings", {"selected_color_mode": "light"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["selected_theme"], "default") + self.assertEqual(response.json()["selected_color_mode"], "light") + + def test_settings_rejects_invalid_selected_theme(self) -> None: + response = self._request("POST", "/api/settings", {"selected_theme": "unknown"}) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") + + def test_settings_rejects_invalid_selected_color_mode(self) -> None: + response = self._request("POST", "/api/settings", {"selected_color_mode": "sepia"}) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") def test_settings_preferred_startup_path_left_rejects_file_path(self) -> None: response = self._request("POST", "/api/settings", {"preferred_startup_path_left": "storage1/file.txt"}) diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index a4777d3..b437d51 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -65,14 +65,19 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="rename-input"', body) self.assertIn('id="rename-apply-btn"', body) self.assertIn('id="settings-general-tab"', body) + self.assertIn('id="settings-interface-tab"', body) self.assertIn('id="settings-logs-tab"', body) self.assertIn('id="settings-show-thumbnails"', body) self.assertIn("Show thumbnails", body) + self.assertIn('id="settings-selected-theme"', body) + self.assertIn("Theme", body) + self.assertNotIn('id="settings-selected-color-mode"', body) self.assertIn('id="settings-startup-path-left"', body) self.assertIn('id="settings-startup-path-right"', body) self.assertIn("Preferred startup path (left)", body) self.assertIn("Preferred startup path (right)", body) self.assertIn('id="settings-general-save-btn"', body) + self.assertIn('id="settings-interface-save-btn"', body) self.assertIn('id="settings-logs-list"', body) self.assertIn('id="viewer-content"', body) self.assertIn('id="editor-modal"', body) @@ -119,7 +124,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertTrue((static_root / "style.css").exists()) app_js = (static_root / "app.js").read_text(encoding="utf-8") self.assertIn('currentPath: "/Volumes"', app_js) - self.assertIn('const THEME_STORAGE_KEY = "webmanager-theme"', app_js) + self.assertIn('selectedTheme: "default"', app_js) + self.assertIn('selectedColorMode: "dark"', app_js) + self.assertIn('function effectiveThemeKey(theme, colorMode)', app_js) self.assertIn("document.documentElement.dataset.theme", app_js) self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js) self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js) @@ -127,12 +134,19 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('await loadSettings();', app_js) self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js) self.assertIn('settings.generalSaveButton.onclick = handlePreferredStartupPathSave;', app_js) + self.assertIn('settings.interfaceSaveButton.onclick = handleInterfaceSave;', app_js) self.assertIn('preferredStartupPathLeft', app_js) self.assertIn('preferredStartupPathRight', app_js) + self.assertIn('selected_theme', app_js) + self.assertIn('selected_color_mode', app_js) + self.assertNotIn("localStorage", app_js) + self.assertNotIn("THEME_STORAGE_KEY", app_js) self.assertIn('preferred_startup_path_left', app_js) self.assertIn('preferred_startup_path_right', app_js) self.assertIn('paneState("left").currentPath = settingsState.preferredStartupPathLeft || "/Volumes";', app_js) self.assertIn('paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes";', app_js) + self.assertIn('applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);', app_js) + self.assertIn('settings.interfaceTab.onclick = () => setSettingsTab("interface");', app_js) self.assertIn('"/api/settings"', app_js) self.assertIn('`/api/files/thumbnail?', app_js) self.assertIn("function iconTypeForEntry(entry)", app_js) @@ -180,8 +194,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn("function rootKeyFromPath(path)", app_js) self.assertIn("function isNestedPath(sourcePath, destinationPath)", app_js) style_css = (static_root / "style.css").read_text(encoding="utf-8") - self.assertIn(':root[data-theme="dark"]', style_css) - self.assertIn(':root[data-theme="light"]', style_css) + self.assertIn(':root[data-theme="default-dark"]', style_css) + self.assertIn(':root[data-theme="default-light"]', style_css) self.assertIn('#theme-toggle', style_css) self.assertIn('.settings-card', style_css) self.assertIn('.settings-tabs', style_css) diff --git a/webui/html/app.js b/webui/html/app.js index 4156e31..6d53bcd 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -57,47 +57,51 @@ let settingsState = { showThumbnails: false, preferredStartupPathLeft: null, preferredStartupPathRight: null, + selectedTheme: "default", + selectedColorMode: "dark", }; let searchState = { pane: "left", path: "/Volumes", query: "", }; -const THEME_STORAGE_KEY = "webmanager-theme"; - -function preferredTheme() { - const stored = window.localStorage.getItem(THEME_STORAGE_KEY); - if (stored === "light" || stored === "dark") { - return stored; - } - if (window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches) { - return "light"; - } - return "dark"; +function effectiveThemeKey(theme, colorMode) { + const family = theme === "default" ? "default" : "default"; + const mode = colorMode === "light" ? "light" : "dark"; + return `${family}-${mode}`; } -function applyTheme(theme) { - const nextTheme = theme === "light" ? "light" : "dark"; +function currentColorMode() { + return (document.documentElement.dataset.theme || "").endsWith("-light") ? "light" : "dark"; +} + +function applyTheme(theme, colorMode) { + const nextTheme = effectiveThemeKey(theme, colorMode); + const mode = colorMode === "light" ? "light" : "dark"; document.documentElement.dataset.theme = nextTheme; const icon = document.getElementById("theme-toggle-icon"); const button = document.getElementById("theme-toggle"); if (icon) { - icon.textContent = nextTheme === "dark" ? "☾" : "☀"; + icon.textContent = mode === "dark" ? "☾" : "☀"; } if (button) { - button.setAttribute("aria-label", `Switch to ${nextTheme === "dark" ? "light" : "dark"} mode`); - button.setAttribute("title", `Switch to ${nextTheme === "dark" ? "light" : "dark"} mode`); + button.setAttribute("aria-label", `Switch to ${mode === "dark" ? "light" : "dark"} mode`); + button.setAttribute("title", `Switch to ${mode === "dark" ? "light" : "dark"} mode`); } if (monacoState.module) { - monacoState.module.editor.setTheme(nextTheme === "light" ? "vs" : "vs-dark"); + monacoState.module.editor.setTheme(mode === "light" ? "vs" : "vs-dark"); } } -function toggleTheme() { - const current = document.documentElement.dataset.theme === "light" ? "light" : "dark"; +async function toggleTheme() { + const current = settingsState.selectedColorMode === "light" ? "light" : "dark"; const next = current === "dark" ? "light" : "dark"; - applyTheme(next); - window.localStorage.setItem(THEME_STORAGE_KEY, next); + try { + const data = await saveSettings({ selected_color_mode: next }); + applyTheme(data.selected_theme, data.selected_color_mode); + } catch (err) { + setError("actions-error", `Theme: ${err.message}`); + } } function paneState(pane) { @@ -223,13 +227,18 @@ function settingsElements() { overlay: document.getElementById("settings-modal"), closeButton: document.getElementById("settings-close-btn"), generalTab: document.getElementById("settings-general-tab"), + interfaceTab: document.getElementById("settings-interface-tab"), logsTab: document.getElementById("settings-logs-tab"), generalPanel: document.getElementById("settings-general-panel"), + interfacePanel: document.getElementById("settings-interface-panel"), showThumbnailsInput: document.getElementById("settings-show-thumbnails"), startupPathLeftInput: document.getElementById("settings-startup-path-left"), startupPathRightInput: document.getElementById("settings-startup-path-right"), generalError: document.getElementById("settings-general-error"), generalSaveButton: document.getElementById("settings-general-save-btn"), + selectedThemeInput: document.getElementById("settings-selected-theme"), + interfaceError: document.getElementById("settings-interface-error"), + interfaceSaveButton: document.getElementById("settings-interface-save-btn"), logsPanel: document.getElementById("settings-logs-panel"), logsList: document.getElementById("settings-logs-list"), logsError: document.getElementById("settings-logs-error"), @@ -507,6 +516,8 @@ async function loadSettings() { settingsState.showThumbnails = !!data.show_thumbnails; settingsState.preferredStartupPathLeft = data.preferred_startup_path_left || null; settingsState.preferredStartupPathRight = data.preferred_startup_path_right || null; + settingsState.selectedTheme = data.selected_theme || "default"; + settingsState.selectedColorMode = data.selected_color_mode || "dark"; const elements = settingsElements(); if (elements.showThumbnailsInput) { elements.showThumbnailsInput.checked = settingsState.showThumbnails; @@ -517,6 +528,9 @@ async function loadSettings() { if (elements.startupPathRightInput) { elements.startupPathRightInput.value = settingsState.preferredStartupPathRight || ""; } + if (elements.selectedThemeInput) { + elements.selectedThemeInput.value = settingsState.selectedTheme; + } } async function saveSettings(update) { @@ -524,6 +538,8 @@ async function saveSettings(update) { settingsState.showThumbnails = !!data.show_thumbnails; settingsState.preferredStartupPathLeft = data.preferred_startup_path_left || null; settingsState.preferredStartupPathRight = data.preferred_startup_path_right || null; + settingsState.selectedTheme = data.selected_theme || "default"; + settingsState.selectedColorMode = data.selected_color_mode || "dark"; const elements = settingsElements(); if (elements.showThumbnailsInput) { elements.showThumbnailsInput.checked = settingsState.showThumbnails; @@ -534,6 +550,10 @@ async function saveSettings(update) { if (elements.startupPathRightInput) { elements.startupPathRightInput.value = settingsState.preferredStartupPathRight || ""; } + if (elements.selectedThemeInput) { + elements.selectedThemeInput.value = settingsState.selectedTheme; + } + applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode); renderPaneItems("left"); renderPaneItems("right"); return data; @@ -1323,7 +1343,7 @@ async function loadMonacoModule() { monacoState.loadPromise = import("https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/+esm") .then((module) => { monacoState.module = module; - module.editor.setTheme(document.documentElement.dataset.theme === "light" ? "vs" : "vs-dark"); + module.editor.setTheme(currentColorMode() === "light" ? "vs" : "vs-dark"); return module; }); } @@ -1359,7 +1379,7 @@ async function ensureMonacoEditor(path, content) { const model = monaco.editor.createModel(content, monacoLanguageForName(baseName(path))); const instance = monaco.editor.create(editor.host, { model, - theme: document.documentElement.dataset.theme === "light" ? "vs" : "vs-dark", + theme: currentColorMode() === "light" ? "vs" : "vs-dark", language: monacoLanguageForName(baseName(path)), automaticLayout: false, lineNumbers: "on", @@ -1868,14 +1888,19 @@ async function submitSearch() { function setSettingsTab(tab) { const elements = settingsElements(); - settingsState.activeTab = tab === "logs" ? "logs" : "general"; + settingsState.activeTab = tab === "logs" ? "logs" : (tab === "interface" ? "interface" : "general"); const isGeneral = settingsState.activeTab === "general"; + const isInterface = settingsState.activeTab === "interface"; + const isLogs = settingsState.activeTab === "logs"; elements.generalTab.classList.toggle("is-active", isGeneral); elements.generalTab.setAttribute("aria-selected", isGeneral ? "true" : "false"); - elements.logsTab.classList.toggle("is-active", !isGeneral); - elements.logsTab.setAttribute("aria-selected", !isGeneral ? "true" : "false"); + elements.interfaceTab.classList.toggle("is-active", isInterface); + elements.interfaceTab.setAttribute("aria-selected", isInterface ? "true" : "false"); + elements.logsTab.classList.toggle("is-active", isLogs); + elements.logsTab.setAttribute("aria-selected", isLogs ? "true" : "false"); elements.generalPanel.classList.toggle("hidden", !isGeneral); - elements.logsPanel.classList.toggle("hidden", isGeneral); + elements.interfacePanel.classList.toggle("hidden", !isInterface); + elements.logsPanel.classList.toggle("hidden", !isLogs); } function formatHistoryLine(item) { @@ -1965,6 +1990,18 @@ async function handlePreferredStartupPathSave() { } } +async function handleInterfaceSave() { + const settings = settingsElements(); + const themeValue = settings.selectedThemeInput ? settings.selectedThemeInput.value : "default"; + settings.interfaceError.textContent = ""; + try { + await saveSettings({ selected_theme: themeValue }); + setStatus("Theme saved"); + } catch (err) { + settings.interfaceError.textContent = err.message; + } +} + function closeSettings() { settingsElements().overlay.classList.add("hidden"); } @@ -1977,7 +2014,11 @@ async function openSettings(tab = "general") { if (settingsState.activeTab === "logs") { await loadHistoryForSettings(); } - (settingsState.activeTab === "logs" ? elements.logsTab : elements.generalTab).focus(); + (settingsState.activeTab === "logs" + ? elements.logsTab + : settingsState.activeTab === "interface" + ? elements.interfaceTab + : elements.generalTab).focus(); } function editorIsDirty() { @@ -2469,12 +2510,14 @@ function setupEvents() { const settings = settingsElements(); settings.closeButton.onclick = closeSettings; settings.generalTab.onclick = () => setSettingsTab("general"); + settings.interfaceTab.onclick = () => setSettingsTab("interface"); settings.logsTab.onclick = async () => { setSettingsTab("logs"); await loadHistoryForSettings(); }; settings.showThumbnailsInput.onchange = handleShowThumbnailsChange; settings.generalSaveButton.onclick = handlePreferredStartupPathSave; + settings.interfaceSaveButton.onclick = handleInterfaceSave; settings.overlay.onclick = (event) => { if (event.target === settings.overlay) { closeSettings(); @@ -2582,10 +2625,11 @@ function setupEvents() { async function init() { setError("actions-error", ""); - applyTheme(preferredTheme()); + applyTheme("default", "dark"); setActivePane("left"); setupEvents(); await loadSettings(); + applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode); paneState("left").currentPath = settingsState.preferredStartupPathLeft || "/Volumes"; paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes"; await loadBrowsePane("left"); diff --git a/webui/html/index.html b/webui/html/index.html index 5df2549..d0c3a5f 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -86,6 +86,7 @@

Settings

+
@@ -108,6 +109,19 @@
+