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 `` 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.` block in `index.css`. const THEME_CLASSES = ['light', 'dark', 'red', 'green', 'blue', 'yellow', 'pink', 'orange'] as const // Reflects `colorScheme` onto `` 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 `` 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(null) const [cssEntries, setCssEntries] = useState([]) const [profiles, setProfiles] = useState([]) const [error, setError] = useState(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) => { 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, } }