Extends ticket 048's light_green pattern (light-inverted surface/border/text scale + light's neutral danger colours, same base and accent hues as the dark-surfaced counterpart) to the remaining five colour schemes across all six files that must stay in sync: index.css, themes.ts, api.ts, settings.rs, useSettingsPanel.ts, and NavMenu.tsx. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
240 lines
6.9 KiB
TypeScript
240 lines
6.9 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 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',
|
|
'light_green',
|
|
'light_red',
|
|
'light_blue',
|
|
'light_yellow',
|
|
'light_pink',
|
|
'light_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>` for the active custom theme entry, if any (ticket 020: every CSS entry is
|
|
// always a selectable theme now, so this is the only injection path — there's no more
|
|
// always-on-overlay mode). `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.id === settings.custom_theme_id) ?? null
|
|
: null
|
|
|
|
useEffect(() => {
|
|
if (settings) applyColorScheme(settings.color_scheme, activeCustomTheme !== null)
|
|
}, [settings, activeCustomTheme])
|
|
|
|
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],
|
|
)
|
|
|
|
// Every entry is always a selectable style, on equal footing with the built-in colour schemes
|
|
// (ticket 020 unified the data model; ticket 047 unified the UI), so there's a single import path.
|
|
const importCssEntry = useCallback(
|
|
(filename: string, content: string) =>
|
|
guarded(async () => {
|
|
await importCss(filename, content)
|
|
await refreshCss()
|
|
})(),
|
|
[guarded, refreshCss],
|
|
)
|
|
|
|
const linkCssEntry = useCallback(
|
|
(pathOrUrl: string) =>
|
|
guarded(async () => {
|
|
await linkCss(pathOrUrl)
|
|
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,
|
|
}
|
|
}
|