Add NavMenu settings/presets UI and profile HTTP routes (ticket 011)
Adds thin HTTP routes for profile CRUD and colour-scheme updates (GET/PATCH /api/settings, GET/POST /api/settings/profiles, DELETE /api/settings/profiles/:name, POST /api/settings/profiles/:name/activate) following the existing CSS-route pattern in api.rs, with activate hot-swapping the running engine's declared params/metrics so switching profiles needs no restart. Builds out the NavMenu frontend with Settings (colour scheme picker, CSS import/list/delete) and Presets (list/create/switch/delete profiles with declaration counts) panels, and a useSettingsPanel hook that applies the active colour scheme and imported CSS to the document live. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
57d23c62e9
commit
801ac36b78
@@ -0,0 +1,181 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
activateProfile,
|
||||
createProfile,
|
||||
cssContentUrl,
|
||||
deleteCssEntry,
|
||||
deleteProfile,
|
||||
fetchCssEntries,
|
||||
fetchProfiles,
|
||||
fetchSettings,
|
||||
importCss,
|
||||
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'
|
||||
|
||||
/**
|
||||
* Resolves `colorScheme` to an effective `light`/`dark` value (following the
|
||||
* OS preference for `system`) and reflects it onto `<html>` as both a class
|
||||
* (`light`/`dark`) and a `data-color-scheme` attribute. The app's Tailwind
|
||||
* classes are mostly hardcoded to a dark palette (see `index.css`) rather
|
||||
* than using `dark:` variants, so "Light" mode is necessarily limited to
|
||||
* whatever `index.css` explicitly overrides for `.light` — this is a known,
|
||||
* documented limitation rather than a full theme system.
|
||||
*/
|
||||
function applyColorScheme(colorScheme: ColorScheme) {
|
||||
const root = document.documentElement
|
||||
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true
|
||||
const effective = colorScheme === 'system' ? (prefersDark ? 'dark' : 'light') : colorScheme
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(effective)
|
||||
root.dataset.colorScheme = colorScheme
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a `<link>` tag per CSS entry into `<head>` (imported entries point
|
||||
* at `GET /api/settings/css/:id/content`, linked entries point straight at
|
||||
* their `path_or_url`), after removing any tags this hook previously
|
||||
* injected — so the rendered app reflects the current entry list immediately
|
||||
* on every change, without a page reload.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns fetching + mutating settings/CSS/profile state for the NavMenu panels
|
||||
* (ticket 011), and applies the colour scheme / imported CSS to the document
|
||||
* as a side effect whenever that state changes — so toggling either is
|
||||
* visible in the running app 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])
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) applyColorScheme(settings.color_scheme)
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
applyCssEntries(cssEntries)
|
||||
}, [cssEntries])
|
||||
|
||||
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 () => {
|
||||
const next = await updateSettings({ colorScheme })
|
||||
setSettings(next)
|
||||
})(),
|
||||
[guarded],
|
||||
)
|
||||
|
||||
const importCssEntry = useCallback(
|
||||
(filename: string, content: string) =>
|
||||
guarded(async () => {
|
||||
await importCss(filename, content)
|
||||
await refreshCss()
|
||||
})(),
|
||||
[guarded, refreshCss],
|
||||
)
|
||||
|
||||
const removeCssEntry = useCallback(
|
||||
(id: string) =>
|
||||
guarded(async () => {
|
||||
await deleteCssEntry(id)
|
||||
await refreshCss()
|
||||
})(),
|
||||
[guarded, refreshCss],
|
||||
)
|
||||
|
||||
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,
|
||||
profiles,
|
||||
error,
|
||||
setColorScheme,
|
||||
importCssEntry,
|
||||
removeCssEntry,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
switchProfile,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user