164 lines
4.6 KiB
TypeScript
164 lines
4.6 KiB
TypeScript
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 `<link>` 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 `<link>` 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<Settings | null>(null)
|
|
const [themes, setThemes] = useState<string[]>([])
|
|
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 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<void>) => {
|
|
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,
|
|
}
|
|
}
|