Files
ottyl/webapp/src/lib/useSettingsPanel.ts
T

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,
}
}