import { useCallback, useEffect, useState } from 'react' import { activateProfile, createProfile, deleteProfile, fetchProfiles, fetchSettings, fetchThemes, themeCssUrl, updateSettings, type ProfileSummary, type Settings, type Theme, } from './api' // `data-*` marker for the single injected `` for the active theme's stylesheet. const INJECTED_THEME_ATTR = 'data-ottyl-theme-css' // `"system"` isn't an embedded theme file — it resolves to one of these two at apply time, based // on the OS light/dark preference. const SYSTEM_LIGHT_THEME = 'light' const SYSTEM_DARK_THEME = 'dark' function resolveTheme(theme: Theme): string { if (theme !== 'system') return theme const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true return prefersDark ? SYSTEM_DARK_THEME : SYSTEM_LIGHT_THEME } // Injects a `` for the resolved theme's stylesheet, replacing any previously-injected one. // `webapp/src/index.css`'s `:root` block is the only styling until this loads, so there's no // flash of unstyled content on first paint. function applyTheme(theme: Theme) { const resolved = resolveTheme(theme) document.querySelectorAll(`link[${INJECTED_THEME_ATTR}]`).forEach((el) => el.remove()) const link = document.createElement('link') link.rel = 'stylesheet' link.href = themeCssUrl(resolved) link.setAttribute(INJECTED_THEME_ATTR, resolved) document.head.appendChild(link) } // Owns fetching + mutating settings/theme/profile state for the NavMenu panels, and applies the // selected theme to the document as a side effect so changes are visible immediately, with no reload. export function useSettingsPanel() { const [settings, setSettings] = useState(null) const [themes, setThemes] = useState([]) const [profiles, setProfiles] = useState([]) const [error, setError] = useState(null) const refreshSettings = useCallback(async () => { const next = await fetchSettings() setSettings(next) return next }, []) const refreshThemes = useCallback(async () => { const next = await fetchThemes() setThemes(next) return next }, []) const refreshProfiles = useCallback(async () => { const next = await fetchProfiles() setProfiles(next) return next }, []) useEffect(() => { refreshSettings().catch(() => {}) refreshThemes().catch(() => {}) refreshProfiles().catch(() => {}) }, [refreshSettings, refreshThemes, refreshProfiles]) useEffect(() => { if (!settings) return applyTheme(settings.theme) if (settings.theme !== 'system') return // Re-resolve if the OS preference changes while "system" is selected. const media = window.matchMedia?.('(prefers-color-scheme: dark)') const onChange = () => applyTheme(settings.theme) media?.addEventListener('change', onChange) return () => media?.removeEventListener('change', onChange) }, [settings]) const guarded = useCallback((action: () => Promise) => { return async () => { try { setError(null) await action() } catch (err) { setError(err instanceof Error ? err.message : String(err)) } } }, []) const setTheme = useCallback( (theme: Theme) => guarded(async () => { const next = await updateSettings({ theme }) 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], ) 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, themes, profiles, error, setTheme, setMaxLogs, setNtfySink, addProfile, removeProfile, switchProfile, } }