From ffeba3c274f53ece72e281fcd5b17f4dd2706d7c Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 18:42:23 +0100 Subject: [PATCH] Merge "custom themes" and "configured/imported CSS" into one mechanism (ticket 012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend already stored both under one CssEntry list (Imported/Linked, differing only by the is_theme flag), but the NavMenu Settings panel split them into two visually-duplicated sections — "Custom themes" with its own upload/link forms and list, and "Import CSS"/"Configured CSS" with a nearly identical upload form and a separate list — and useSettingsPanel exposed parallel importCssEntry/importThemeEntry and (theme-only) linkThemeEntry wrappers around the same underlying API calls. Consolidate to one "CSS" section: a single list of all CssEntries (each tagged "theme" and selectable when is_theme is set), one import form and one link form, each with a "Use as theme" checkbox instead of a duplicate theme-only form. useSettingsPanel now exposes a single importCssEntry(name, content, isTheme) and a generic linkCssEntry(url, isTheme) — the latter also fixes the prior asymmetry where linking (vs. uploading) was only available for themes, not arbitrary CSS. No backend or settings-schema changes were needed: is_theme already persists correctly, so existing custom-theme and imported-CSS configuration keeps working unmodified under the unified UI. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- ...12-merge-custom-themes-and-imported-css.md | 14 - webapp/src/components/NavMenu.tsx | 318 ++++++++---------- webapp/src/lib/useSettingsPanel.ts | 25 +- 3 files changed, 148 insertions(+), 209 deletions(-) delete mode 100644 tickets/012-merge-custom-themes-and-imported-css.md diff --git a/tickets/012-merge-custom-themes-and-imported-css.md b/tickets/012-merge-custom-themes-and-imported-css.md deleted file mode 100644 index 07ea314..0000000 --- a/tickets/012-merge-custom-themes-and-imported-css.md +++ /dev/null @@ -1,14 +0,0 @@ -# Merge "custom themes" and "configured/imported CSS" into one concept - -Custom themes and configured/imported CSS are the same thing. - -## Current behavior (notes) -- Imported CSS (as pasted content or file upload) is base64-encoded and stored in the settings file. -- The "name" field is not a file name — it's a human-readable name, labeled just "name" in the UI. It gets displayed alongside the other (built-in) themes. -- The built-in themes are also auto-inserted into the settings JSON. -- Imported CSS entries can be "deleted", which removes them from the settings JSON. - -## Acceptance criteria -- The "custom themes" and "configured/imported CSS" features are consolidated into a single mechanism. -- Duplicate UI, settings, and code paths for the two are removed. -- Existing user configuration for either is migrated or aliased so it keeps working under the unified mechanism. diff --git a/webapp/src/components/NavMenu.tsx b/webapp/src/components/NavMenu.tsx index 3f312ee..318f93e 100644 --- a/webapp/src/components/NavMenu.tsx +++ b/webapp/src/components/NavMenu.tsx @@ -184,13 +184,13 @@ function PresetsDropdown({ panel }: { panel: ReturnType } function SettingsPanel({ panel }: { panel: ReturnType }) { - const [filename, setFilename] = useState('') - const [content, setContent] = useState('') const [maxLogsInput, setMaxLogsInput] = useState('') - const [themeUploadError, setThemeUploadError] = useState(null) - const [themeFilename, setThemeFilename] = useState('') - const [themeContent, setThemeContent] = useState('') - const [themeLinkUrl, setThemeLinkUrl] = useState('') + const [cssFilename, setCssFilename] = useState('') + const [cssContent, setCssContent] = useState('') + const [cssIsTheme, setCssIsTheme] = useState(false) + const [cssUploadError, setCssUploadError] = useState(null) + const [cssLinkUrl, setCssLinkUrl] = useState('') + const [cssLinkIsTheme, setCssLinkIsTheme] = useState(false) const [ntfyTopicInput, setNtfyTopicInput] = useState('') const [ntfyServerInput, setNtfyServerInput] = useState('') const [ntfyTestError, setNtfyTestError] = useState(null) @@ -246,19 +246,41 @@ function SettingsPanel({ panel }: { panel: ReturnType } panel.setMaxLogs(Math.floor(parsed)) } - async function handleFileChange(e: React.ChangeEvent) { + async function handleCssFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0] if (!file) return - setFilename(file.name) - setContent(await file.text()) + setCssFilename(file.name) + setCssContent(await file.text()) + setCssUploadError(null) } - async function handleImport(e: React.FormEvent) { + // Single import path for both "configured/imported CSS" and "custom + // themes" (ticket 012) — `cssIsTheme` is the only thing that distinguishes + // them, so a theme upload is only additionally validated against the + // canonical `--c-*` variable set before it's sent. + async function handleCssImport(e: React.FormEvent) { e.preventDefault() - if (!filename.trim() || !content.trim()) return - await panel.importCssEntry(filename.trim(), content) - setFilename('') - setContent('') + setCssUploadError(null) + if (!cssFilename.trim() || !cssContent.trim()) return + if (cssIsTheme) { + const { missing } = parseThemeCss(cssContent) + if (missing.length > 0) { + setCssUploadError(`Missing variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`) + return + } + } + await panel.importCssEntry(cssFilename.trim(), cssContent, cssIsTheme) + setCssFilename('') + setCssContent('') + setCssIsTheme(false) + } + + async function handleCssLink(e: React.FormEvent) { + e.preventDefault() + if (!cssLinkUrl.trim()) return + await panel.linkCssEntry(cssLinkUrl.trim(), cssLinkIsTheme) + setCssLinkUrl('') + setCssLinkIsTheme(false) } function downloadBuiltinTheme(name: string) { @@ -267,7 +289,7 @@ function SettingsPanel({ panel }: { panel: ReturnType } downloadThemeFile(`${name}.css`, generateThemeCss(vars)) } - async function downloadThemeEntry(entry: CssEntry) { + async function downloadCssEntry(entry: CssEntry) { if (entry.kind === 'imported') { const res = await fetch(cssContentUrl(entry.id)) const text = await res.text() @@ -277,38 +299,6 @@ function SettingsPanel({ panel }: { panel: ReturnType } } } - async function handleThemeFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0] - if (!file) return - setThemeFilename(file.name) - setThemeContent(await file.text()) - setThemeUploadError(null) - } - - async function handleThemeUpload(e: React.FormEvent) { - e.preventDefault() - setThemeUploadError(null) - if (!themeFilename.trim() || !themeContent.trim()) return - const { missing } = parseThemeCss(themeContent) - if (missing.length > 0) { - setThemeUploadError(`Missing variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`) - return - } - await panel.importThemeEntry(themeFilename.trim(), themeContent) - setThemeFilename('') - setThemeContent('') - } - - async function handleThemeLink(e: React.FormEvent) { - e.preventDefault() - if (!themeLinkUrl.trim()) return - await panel.linkThemeEntry(themeLinkUrl.trim()) - setThemeLinkUrl('') - } - - const themeEntries = panel.cssEntries.filter((entry) => entry.is_theme) - const arbitraryCssEntries = panel.cssEntries.filter((entry) => !entry.is_theme) - return (
@@ -343,96 +333,6 @@ function SettingsPanel({ panel }: { panel: ReturnType }
-
-

Custom themes

- {themeEntries.length === 0 ? ( -
No custom themes yet.
- ) : ( -
    - {themeEntries.map((entry) => ( -
  • - -
    - - {entry.kind === 'imported' ? ( - - ) : ( - - — - - )} -
    -
  • - ))} -
- )} - -
- - setThemeFilename(e.target.value)} - className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none" - /> - {themeUploadError &&
{themeUploadError}
} - -
- -
- setThemeLinkUrl(e.target.value)} - className="min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none" - /> - -
-
-

Log retention

@@ -489,28 +389,97 @@ function SettingsPanel({ panel }: { panel: ReturnType }
-

Import CSS

-
+
+

CSS

+ + imported/linked stylesheets — check "Use as theme" to make one a selectable alternative to the built-in + colour schemes above instead of an always-on overlay + +
+ {panel.cssEntries.length === 0 ? ( +
No CSS configured.
+ ) : ( +
    + {panel.cssEntries.map((entry) => ( +
  • +
    + {entry.is_theme ? ( + + ) : ( +
    {entry.filename ?? entry.path_or_url}
    + )} +
    + {entry.kind} + {entry.is_theme ? ' · theme' : ''} +
    +
    +
    + + {entry.kind === 'imported' ? ( + + ) : ( + + — + + )} +
    +
  • + ))} +
+ )} + + setFilename(e.target.value)} + placeholder="name.css" + value={cssFilename} + onChange={(e) => setCssFilename(e.target.value)} className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none" />