Files
ottyl/webapp/src/lib/useSettingsPanel.ts
T
Hugh KaznowskiandClaude Sonnet 5 ffeba3c274 Merge "custom themes" and "configured/imported CSS" into one mechanism (ticket 012)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 18:42:40 +01:00

245 lines
7.7 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react'
import {
activateProfile,
createProfile,
cssContentUrl,
deleteCssEntry,
deleteProfile,
fetchCssEntries,
fetchProfiles,
fetchSettings,
importCss,
linkCss,
updateSettings,
type ColorScheme,
type CssEntry,
type ProfileSummary,
type Settings,
} from './api'
// `data-*` marker used to find/remove previously-injected CSS tags on re-apply.
const INJECTED_CSS_ATTR = 'data-ottyl-css-id'
// `data-*` marker for the single injected `<link>` for the active custom theme, if any.
const INJECTED_THEME_ATTR = 'data-ottyl-theme-css-id'
// Themes selectable independently of `system`, each backed by a `html.<name>` block in `index.css`.
const THEME_CLASSES = ['light', 'dark', 'red', 'green', 'blue', 'yellow', 'pink', 'orange'] as const
// Reflects `colorScheme` onto `<html>` as a class + `data-color-scheme`; when a custom theme is active, no built-in class is added at all, since a class selector would out-specificity its injected `:root` override (see `applyCustomThemeCss`) and mask it.
function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) {
const root = document.documentElement
root.classList.remove(...THEME_CLASSES)
if (customThemeActive) {
root.dataset.colorScheme = 'custom'
return
}
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true
const effective = colorScheme === 'system' ? (prefersDark ? 'dark' : 'light') : colorScheme
root.classList.add(effective)
root.dataset.colorScheme = colorScheme
}
// Injects a `<link>` per CSS entry, replacing any this hook previously injected. Only non-theme entries are applied unconditionally; theme entries are applied separately, only while selected — see `applyCustomThemeCss`.
function applyCssEntries(entries: CssEntry[]) {
document.querySelectorAll(`link[${INJECTED_CSS_ATTR}]`).forEach((el) => el.remove())
for (const entry of entries) {
const href = entry.kind === 'imported' ? cssContentUrl(entry.id) : entry.path_or_url
if (!href) continue
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = href
link.setAttribute(INJECTED_CSS_ATTR, entry.id)
document.head.appendChild(link)
}
}
// Same injection mechanism as `applyCssEntries`, kept separate since this one is conditional on `settings.custom_theme_id`; `entry` null just removes any previously-injected tag.
function applyCustomThemeCss(entry: CssEntry | null) {
document.querySelectorAll(`link[${INJECTED_THEME_ATTR}]`).forEach((el) => el.remove())
if (!entry) return
const href = entry.kind === 'imported' ? cssContentUrl(entry.id) : entry.path_or_url
if (!href) return
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = href
link.setAttribute(INJECTED_THEME_ATTR, entry.id)
document.head.appendChild(link)
}
// Owns fetching + mutating settings/CSS/profile state for the NavMenu panels, and applies colour scheme / imported CSS to the document as a side effect so changes are visible immediately, with no reload.
export function useSettingsPanel() {
const [settings, setSettings] = useState<Settings | null>(null)
const [cssEntries, setCssEntries] = useState<CssEntry[]>([])
const [profiles, setProfiles] = useState<ProfileSummary[]>([])
const [error, setError] = useState<string | null>(null)
const refreshSettings = useCallback(async () => {
const next = await fetchSettings()
setSettings(next)
return next
}, [])
const refreshCss = useCallback(async () => {
const next = await fetchCssEntries()
setCssEntries(next)
return next
}, [])
const refreshProfiles = useCallback(async () => {
const next = await fetchProfiles()
setProfiles(next)
return next
}, [])
useEffect(() => {
refreshSettings().catch(() => {})
refreshCss().catch(() => {})
refreshProfiles().catch(() => {})
}, [refreshSettings, refreshCss, refreshProfiles])
const activeCustomTheme = settings?.custom_theme_id
? cssEntries.find((entry) => entry.is_theme && entry.id === settings.custom_theme_id) ?? null
: null
useEffect(() => {
if (settings) applyColorScheme(settings.color_scheme, activeCustomTheme !== null)
}, [settings, activeCustomTheme])
useEffect(() => {
applyCssEntries(cssEntries.filter((entry) => !entry.is_theme))
}, [cssEntries])
useEffect(() => {
applyCustomThemeCss(activeCustomTheme)
}, [activeCustomTheme])
const guarded = useCallback((action: () => Promise<void>) => {
return async () => {
try {
setError(null)
await action()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}
}, [])
const setColorScheme = useCallback(
(colorScheme: ColorScheme) =>
guarded(async () => {
// Selecting a built-in scheme always deselects any custom theme — the two are mutually exclusive (see `applyColorScheme`).
const next = await updateSettings({ colorScheme, customThemeId: null })
setSettings(next)
})(),
[guarded],
)
const setCustomTheme = useCallback(
(id: string) =>
guarded(async () => {
const next = await updateSettings({ customThemeId: id })
setSettings(next)
})(),
[guarded],
)
const setMaxLogs = useCallback(
(maxLogs: number | null) =>
guarded(async () => {
const next = await updateSettings({ maxLogs })
setSettings(next)
})(),
[guarded],
)
const setNtfySink = useCallback(
(ntfyTopic: string | null, ntfyServer: string | null) =>
guarded(async () => {
const next = await updateSettings({ ntfyTopic, ntfyServer })
setSettings(next)
})(),
[guarded],
)
// One entry point for both "configured/imported CSS" and "custom themes" —
// `isTheme` is the only thing distinguishing them (ticket 012), so there's
// no separate theme-only import path.
const importCssEntry = useCallback(
(filename: string, content: string, isTheme = false) =>
guarded(async () => {
await importCss(filename, content, isTheme)
await refreshCss()
})(),
[guarded, refreshCss],
)
const linkCssEntry = useCallback(
(pathOrUrl: string, isTheme = false) =>
guarded(async () => {
await linkCss(pathOrUrl, isTheme)
await refreshCss()
})(),
[guarded, refreshCss],
)
const removeCssEntry = useCallback(
(id: string) =>
guarded(async () => {
await deleteCssEntry(id)
await refreshCss()
// The removed entry may have been the active custom theme — fall back to `color_scheme` rather than leaving a dangling selection.
if (settings?.custom_theme_id === id) {
const next = await updateSettings({ customThemeId: null })
setSettings(next)
}
})(),
[guarded, refreshCss, settings],
)
const addProfile = useCallback(
(name: string) =>
guarded(async () => {
await createProfile(name)
await refreshProfiles()
})(),
[guarded, refreshProfiles],
)
const removeProfile = useCallback(
(name: string) =>
guarded(async () => {
await deleteProfile(name)
await Promise.all([refreshProfiles(), refreshSettings()])
})(),
[guarded, refreshProfiles, refreshSettings],
)
const switchProfile = useCallback(
(name: string) =>
guarded(async () => {
const next = await activateProfile(name)
setSettings(next)
})(),
[guarded],
)
return {
settings,
cssEntries,
activeCustomTheme,
profiles,
error,
setColorScheme,
setCustomTheme,
setMaxLogs,
setNtfySink,
importCssEntry,
linkCssEntry,
removeCssEntry,
addProfile,
removeProfile,
switchProfile,
}
}