Remove uploading css functionality. Themes are hard-coded. Change the code
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Stats } from '../lib/api'
|
||||
import { cssContentUrl, sendTestAlertNotification, type ColorScheme, type CssEntry } from '../lib/api'
|
||||
import { sendTestAlertNotification } from '../lib/api'
|
||||
import { useSettingsPanel } from '../lib/useSettingsPanel'
|
||||
import { useDocsNav, type DocsTarget } from '../lib/docsNav'
|
||||
import { DocsPanel } from './Docs'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
import { BUILTIN_THEMES, downloadThemeFile, generateThemeCss, parseThemeCss } from '../lib/themes'
|
||||
import ottylIcon from '../assets/ottyl-icon.svg'
|
||||
|
||||
type PanelName = 'settings' | 'docs' | null
|
||||
@@ -18,27 +17,9 @@ const PANEL_TITLES: Record<Exclude<PanelName, null>, string> = {
|
||||
/** Mirrors the backend's `profile::DEFAULT_PROFILE_NAME` — always exists, can't be deleted. */
|
||||
const DEFAULT_PROFILE_NAME = 'Default'
|
||||
|
||||
const COLOR_SCHEMES: ColorScheme[] = [
|
||||
'system',
|
||||
'light',
|
||||
'dark',
|
||||
'red',
|
||||
'green',
|
||||
'blue',
|
||||
'yellow',
|
||||
'pink',
|
||||
'orange',
|
||||
'light_green',
|
||||
'light_red',
|
||||
'light_blue',
|
||||
'light_yellow',
|
||||
'light_pink',
|
||||
'light_orange',
|
||||
]
|
||||
|
||||
/** Display label for a `ColorScheme`: underscores become spaces so `capitalize` (which titles each word) renders e.g. "light_green" as "Light Green". */
|
||||
function schemeLabel(scheme: ColorScheme): string {
|
||||
return scheme.replace(/_/g, ' ')
|
||||
/** Display label for a theme name: underscores become spaces so `capitalize` (which titles each word) renders e.g. "light_green" as "Light Green". */
|
||||
function themeLabel(theme: string): string {
|
||||
return theme.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
/** Ticket 073: plain `Xms` under a second (this stat is normally near-zero, so that's the common case), a humantime-style `Xs`/`Xm` above it — mirrors the backend's own humantime-duration inputs (see `Docs.tsx`'s window-parsing note) without pulling in a full duration-formatting library for one stat. */
|
||||
@@ -213,10 +194,6 @@ function PresetsDropdown({ panel }: { panel: ReturnType<typeof useSettingsPanel>
|
||||
|
||||
function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
|
||||
const [maxLogsInput, setMaxLogsInput] = useState('')
|
||||
const [cssFilename, setCssFilename] = useState('')
|
||||
const [cssContent, setCssContent] = useState('')
|
||||
const [cssUploadError, setCssUploadError] = useState<string | null>(null)
|
||||
const [cssLinkUrl, setCssLinkUrl] = useState('')
|
||||
const [ntfyTopicInput, setNtfyTopicInput] = useState('')
|
||||
const [ntfyServerInput, setNtfyServerInput] = useState('')
|
||||
const [ntfyTestError, setNtfyTestError] = useState<string | null>(null)
|
||||
@@ -272,198 +249,43 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
||||
panel.setMaxLogs(Math.floor(parsed))
|
||||
}
|
||||
|
||||
async function handleCssFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setCssFilename(file.name)
|
||||
setCssContent(await file.text())
|
||||
setCssUploadError(null)
|
||||
}
|
||||
|
||||
// Every imported/linked stylesheet is always a selectable style, listed alongside the built-in
|
||||
// colour schemes (ticket 020/047), so it's always validated against the canonical `--c-*`
|
||||
// variable set before it's sent — that's a floor, not a ceiling: the stylesheet is free to also
|
||||
// carry rules for any other selector, up to and including a full layout redesign.
|
||||
async function handleCssImport(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setCssUploadError(null)
|
||||
if (!cssFilename.trim() || !cssContent.trim()) return
|
||||
const { missing } = parseThemeCss(cssContent)
|
||||
if (missing.length > 0) {
|
||||
setCssUploadError(`Missing variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`)
|
||||
return
|
||||
}
|
||||
await panel.importCssEntry(cssFilename.trim(), cssContent)
|
||||
setCssFilename('')
|
||||
setCssContent('')
|
||||
}
|
||||
|
||||
async function handleCssLink(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!cssLinkUrl.trim()) return
|
||||
await panel.linkCssEntry(cssLinkUrl.trim())
|
||||
setCssLinkUrl('')
|
||||
}
|
||||
|
||||
function downloadBuiltinTheme(name: string) {
|
||||
const vars = BUILTIN_THEMES[name]
|
||||
if (!vars) return
|
||||
downloadThemeFile(`${name}.css`, generateThemeCss(vars))
|
||||
}
|
||||
|
||||
async function downloadCssEntry(entry: CssEntry) {
|
||||
if (entry.kind === 'imported') {
|
||||
const res = await fetch(cssContentUrl(entry.id))
|
||||
const text = await res.text()
|
||||
downloadThemeFile(entry.filename ?? `${entry.id}.css`, text)
|
||||
} else if (entry.path_or_url) {
|
||||
window.open(entry.path_or_url, '_blank', 'noopener')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 text-sm text-text-secondary">
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">Style</h3>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">Theme</h3>
|
||||
<span className="text-[11px] text-text-faint">
|
||||
one list of interchangeable styles — a built-in palette or your own imported/linked stylesheet. Any of
|
||||
them can be a simple colour swap or a full layout redesign; only one is active at a time.
|
||||
each theme is a complete stylesheet (colours, spacing, fonts, sizes, ...); only one is active at a time.
|
||||
To add your own, drop a `.css` file in `themes/` and rebuild.
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-3 flex flex-wrap gap-1">
|
||||
{COLOR_SCHEMES.map((scheme) => (
|
||||
<div key={scheme} className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => panel.setColorScheme(scheme)}
|
||||
className={`rounded-l border px-2 py-1 text-xs capitalize ${
|
||||
!panel.activeCustomTheme && panel.settings?.color_scheme === scheme
|
||||
? 'border-border-strong bg-surface-2 text-text'
|
||||
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
{schemeLabel(scheme)}
|
||||
</button>
|
||||
{scheme !== 'system' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadBuiltinTheme(scheme)}
|
||||
className="rounded-r border border-l-0 border-border-soft px-1.5 py-1 text-xs text-text-faint hover:bg-surface-2 hover:text-text"
|
||||
aria-label={`Download ${schemeLabel(scheme)} theme`}
|
||||
title="Download as .css"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => panel.setTheme('system')}
|
||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||
panel.settings?.theme === 'system'
|
||||
? 'border-border-strong bg-surface-2 text-text'
|
||||
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
System
|
||||
</button>
|
||||
{panel.themes.map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
type="button"
|
||||
onClick={() => panel.setTheme(theme)}
|
||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||
panel.settings?.theme === theme
|
||||
? 'border-border-strong bg-surface-2 text-text'
|
||||
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
{themeLabel(theme)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{panel.cssEntries.length === 0 ? (
|
||||
<div className="mb-2 text-xs text-text-faint">No styles imported or linked yet.</div>
|
||||
) : (
|
||||
<ul className="mb-2 flex flex-col gap-1">
|
||||
{panel.cssEntries.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => panel.setCustomTheme(entry.id)}
|
||||
className={`block w-full truncate text-left capitalize ${
|
||||
panel.activeCustomTheme?.id === entry.id ? 'text-text' : 'text-text-muted hover:text-text'
|
||||
}`}
|
||||
>
|
||||
{entry.filename ?? entry.path_or_url}
|
||||
</button>
|
||||
<div className="text-[10px] uppercase text-text-faint">{entry.kind}</div>
|
||||
</div>
|
||||
<div className="ml-2 flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadCssEntry(entry)}
|
||||
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-text"
|
||||
aria-label={`Download ${entry.filename ?? entry.path_or_url}`}
|
||||
title="Download as .css"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
{entry.kind === 'imported' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => panel.removeCssEntry(entry.id)}
|
||||
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
|
||||
aria-label={`Delete ${entry.filename}`}
|
||||
>
|
||||
delete
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-text-faintest" title="Linked CSS isn't deletable here">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="mb-2 text-[11px] text-text-faint">
|
||||
Add your own style below — a stylesheet, uploaded or linked by URL/path. It must at least define every{' '}
|
||||
<code>--c-*</code> variable the built-ins do, but nothing stops it from also targeting any other selector
|
||||
in the app (layout, spacing, fonts, anything) — it isn't limited to palette overrides.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleCssImport} className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept=".css,text/css"
|
||||
onChange={handleCssFileChange}
|
||||
className="text-xs text-text-muted file:mr-2 file:rounded file:border file:border-border-soft file:bg-surface-2 file:px-2 file:py-1 file:text-text"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="enter name"
|
||||
value={cssFilename}
|
||||
onChange={(e) => setCssFilename(e.target.value)}
|
||||
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Paste CSS content here"
|
||||
value={cssContent}
|
||||
onChange={(e) => setCssContent(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-y rounded border border-border-soft bg-surface-1 px-2 py-1 font-mono text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||
/>
|
||||
{cssUploadError && <div className="text-xs text-danger-text-strong">{cssUploadError}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form onSubmit={handleCssLink} className="mt-2 flex flex-col gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://example.com/style.css"
|
||||
value={cssLinkUrl}
|
||||
onChange={(e) => setCssLinkUrl(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
+9
-411
@@ -12,36 +12,16 @@
|
||||
* danger banners, accent highlights) is a plain CSS custom property here,
|
||||
* bridged into real Tailwind utilities (bg-surface-0, text-text-muted,
|
||||
* border-accent, ...) via `@theme inline`. Components use those utilities
|
||||
* instead of hardcoded palette classes (bg-neutral-950, text-sky-400, ...),
|
||||
* so re-declaring the `--c-*` variables under a `html.<theme>` selector
|
||||
* re-skins the whole app with no per-component changes.
|
||||
* instead of hardcoded palette classes (bg-neutral-950, text-sky-400, ...).
|
||||
*
|
||||
* Derivation: `dark` uses one true black-based neutral (S=0) scale, `light`
|
||||
* is its exact lightness mirror — these two stay grey throughout. (There
|
||||
* used to be a separate `grey` theme sharing `dark`'s neutral scale with
|
||||
* only its accent trio differing; it was a near-duplicate of `dark` and was
|
||||
* removed in favour of a single `dark` theme — ticket 004. Settings saved
|
||||
* with `color_scheme: "grey"` still load fine: the backend aliases it to
|
||||
* `dark`, see `ColorScheme` in `src/settings.rs`.)
|
||||
* Each named theme (red/green/blue/yellow/pink/orange) instead tints its
|
||||
* whole surface/border/text scale in its own hue at low saturation (so the
|
||||
* chrome itself reads as "a shade of red", not grey-with-a-red-light), and
|
||||
* pulls its accent trio from two *other* hues on the wheel: --c-accent and
|
||||
* --c-accent-text share the base hue's complement (base+180°), while
|
||||
* --c-accent-soft sits at a split-complement (~base+150°/-150°) — a
|
||||
* "composite" accent distinct from the straight complement. To add a new
|
||||
* themed variant: pick a base hue H, a complement C = H+180, and a
|
||||
* composite K = H+150 (mod 360), then fill --c-surface-, --c-border-,
|
||||
* --c-text- as hsl(H, ...) at the saturation/lightness steps below, accent
|
||||
* + accent-text as hsl(C, ...), and accent-soft as hsl(K, ...).
|
||||
*
|
||||
* --c-accent (base look, unchanged above) is joined by two more accent
|
||||
* colours (ticket 048) so charts/highlights can draw from a 3-colour set
|
||||
* instead of one hue: --c-accent-2 sits at H+90 and --c-accent-3 at H+270 —
|
||||
* a quadrature pair straddling the base hue, distinct from both the
|
||||
* complement (accent) and the split-complement (accent-soft). These are
|
||||
* purely additive: nothing that already reads --c-accent/-soft/-text
|
||||
* changes look.
|
||||
* The actual theme files live in `themes/` at the repo root (embedded into
|
||||
* the binary at build time — see `THEMES` in `src/api/mod.rs`), one CSS file
|
||||
* per theme, each free to redeclare `--c-*` under `:root` and/or add any
|
||||
* other rules (spacing, fonts, layout, ...) it wants. The `:root` block below
|
||||
* is only the pre-theme-load fallback (identical to `themes/dark.css`) so
|
||||
* there's no flash of unstyled content before the selected theme's
|
||||
* stylesheet is injected — see `useSettingsPanel.ts`. To add a new theme,
|
||||
* drop another file in `themes/` and rebuild; no code changes needed.
|
||||
*/
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
@@ -74,388 +54,6 @@
|
||||
--c-accent-3: #4ade80;
|
||||
}
|
||||
|
||||
/* Exact lightness mirror of :root's neutral-950..50 scale (Tailwind neutral,
|
||||
steps flipped: 950<->50, 900<->100, 800<->200, 700<->300, 600<->400). */
|
||||
html.light {
|
||||
--c-surface-0: #fafafa;
|
||||
--c-surface-1: #f5f5f5;
|
||||
--c-surface-2: #e5e5e5;
|
||||
|
||||
--c-border: #e5e5e5;
|
||||
--c-border-soft: #d4d4d4;
|
||||
--c-border-faint: #f5f5f5;
|
||||
--c-border-strong: #525252;
|
||||
|
||||
--c-text: #171717;
|
||||
--c-text-secondary: #404040;
|
||||
--c-text-muted: #525252;
|
||||
--c-text-subtle: #737373;
|
||||
--c-text-faint: #a3a3a3;
|
||||
--c-text-faintest: #d4d4d4;
|
||||
|
||||
--c-danger-bg: #fee2e2;
|
||||
--c-danger-border: #fca5a5;
|
||||
--c-danger-text: #b91c1c;
|
||||
--c-danger-text-strong: #991b1b;
|
||||
|
||||
--c-accent: #0284c7;
|
||||
--c-accent-soft: #7dd3fc;
|
||||
--c-accent-text: #0369a1;
|
||||
--c-accent-2: #7c3aed;
|
||||
--c-accent-3: #16a34a;
|
||||
}
|
||||
|
||||
/* base hue 0 (red): surfaces/borders/text tinted red; accent complement 180
|
||||
(cyan), accent-soft composite 150 (spring green) */
|
||||
html.red {
|
||||
--c-surface-0: hsl(0 32% 10%);
|
||||
--c-surface-1: hsl(0 28% 16%);
|
||||
--c-surface-2: hsl(0 26% 22%);
|
||||
|
||||
--c-border: hsl(0 24% 24%);
|
||||
--c-border-soft: hsl(0 22% 33%);
|
||||
--c-border-faint: hsl(0 28% 16%);
|
||||
--c-border-strong: hsl(0 15% 62%);
|
||||
|
||||
--c-text: hsl(0 15% 94%);
|
||||
--c-text-secondary: hsl(0 12% 82%);
|
||||
--c-text-muted: hsl(0 10% 65%);
|
||||
--c-text-subtle: hsl(0 8% 50%);
|
||||
--c-text-faint: hsl(0 8% 38%);
|
||||
--c-text-faintest: hsl(0 10% 28%);
|
||||
|
||||
--c-accent: hsl(180 70% 62%);
|
||||
--c-accent-soft: hsl(150 55% 34%);
|
||||
--c-accent-text: hsl(180 75% 80%);
|
||||
--c-accent-2: hsl(90 65% 58%);
|
||||
--c-accent-3: hsl(270 65% 68%);
|
||||
}
|
||||
|
||||
/* base hue 140 (green): surfaces/borders/text tinted green; accent complement
|
||||
320 (magenta), accent-soft composite 290 (purple) */
|
||||
html.green {
|
||||
--c-surface-0: hsl(140 32% 10%);
|
||||
--c-surface-1: hsl(140 28% 16%);
|
||||
--c-surface-2: hsl(140 26% 22%);
|
||||
|
||||
--c-border: hsl(140 24% 24%);
|
||||
--c-border-soft: hsl(140 22% 33%);
|
||||
--c-border-faint: hsl(140 28% 16%);
|
||||
--c-border-strong: hsl(140 15% 62%);
|
||||
|
||||
--c-text: hsl(140 15% 94%);
|
||||
--c-text-secondary: hsl(140 12% 82%);
|
||||
--c-text-muted: hsl(140 10% 65%);
|
||||
--c-text-subtle: hsl(140 8% 50%);
|
||||
--c-text-faint: hsl(140 8% 38%);
|
||||
--c-text-faintest: hsl(140 10% 28%);
|
||||
|
||||
--c-accent: hsl(320 70% 62%);
|
||||
--c-accent-soft: hsl(290 55% 34%);
|
||||
--c-accent-text: hsl(320 75% 80%);
|
||||
--c-accent-2: hsl(230 65% 62%);
|
||||
--c-accent-3: hsl(50 65% 58%);
|
||||
}
|
||||
|
||||
/* base hue 210 (blue): surfaces/borders/text tinted blue; accent complement
|
||||
30 (orange), accent-soft composite 0 (red) */
|
||||
html.blue {
|
||||
--c-surface-0: hsl(210 32% 10%);
|
||||
--c-surface-1: hsl(210 28% 16%);
|
||||
--c-surface-2: hsl(210 26% 22%);
|
||||
|
||||
--c-border: hsl(210 24% 24%);
|
||||
--c-border-soft: hsl(210 22% 33%);
|
||||
--c-border-faint: hsl(210 28% 16%);
|
||||
--c-border-strong: hsl(210 15% 62%);
|
||||
|
||||
--c-text: hsl(210 15% 94%);
|
||||
--c-text-secondary: hsl(210 12% 82%);
|
||||
--c-text-muted: hsl(210 10% 65%);
|
||||
--c-text-subtle: hsl(210 8% 50%);
|
||||
--c-text-faint: hsl(210 8% 38%);
|
||||
--c-text-faintest: hsl(210 10% 28%);
|
||||
|
||||
--c-accent: hsl(30 70% 62%);
|
||||
--c-accent-soft: hsl(0 55% 34%);
|
||||
--c-accent-text: hsl(30 75% 80%);
|
||||
--c-accent-2: hsl(300 65% 68%);
|
||||
--c-accent-3: hsl(120 65% 55%);
|
||||
}
|
||||
|
||||
/* base hue 50 (yellow): surfaces/borders/text tinted yellow; accent
|
||||
complement 230 (blue), accent-soft composite 200 (azure) */
|
||||
html.yellow {
|
||||
--c-surface-0: hsl(50 32% 10%);
|
||||
--c-surface-1: hsl(50 28% 16%);
|
||||
--c-surface-2: hsl(50 26% 22%);
|
||||
|
||||
--c-border: hsl(50 24% 24%);
|
||||
--c-border-soft: hsl(50 22% 33%);
|
||||
--c-border-faint: hsl(50 28% 16%);
|
||||
--c-border-strong: hsl(50 15% 62%);
|
||||
|
||||
--c-text: hsl(50 15% 94%);
|
||||
--c-text-secondary: hsl(50 12% 82%);
|
||||
--c-text-muted: hsl(50 10% 65%);
|
||||
--c-text-subtle: hsl(50 8% 50%);
|
||||
--c-text-faint: hsl(50 8% 38%);
|
||||
--c-text-faintest: hsl(50 10% 28%);
|
||||
|
||||
--c-accent: hsl(230 70% 62%);
|
||||
--c-accent-soft: hsl(200 55% 34%);
|
||||
--c-accent-text: hsl(230 75% 80%);
|
||||
--c-accent-2: hsl(140 65% 55%);
|
||||
--c-accent-3: hsl(320 65% 68%);
|
||||
}
|
||||
|
||||
/* base hue 330 (pink): surfaces/borders/text tinted pink; accent complement
|
||||
150 (spring green), accent-soft composite 120 (green) */
|
||||
html.pink {
|
||||
--c-surface-0: hsl(330 32% 10%);
|
||||
--c-surface-1: hsl(330 28% 16%);
|
||||
--c-surface-2: hsl(330 26% 22%);
|
||||
|
||||
--c-border: hsl(330 24% 24%);
|
||||
--c-border-soft: hsl(330 22% 33%);
|
||||
--c-border-faint: hsl(330 28% 16%);
|
||||
--c-border-strong: hsl(330 15% 62%);
|
||||
|
||||
--c-text: hsl(330 15% 94%);
|
||||
--c-text-secondary: hsl(330 12% 82%);
|
||||
--c-text-muted: hsl(330 10% 65%);
|
||||
--c-text-subtle: hsl(330 8% 50%);
|
||||
--c-text-faint: hsl(330 8% 38%);
|
||||
--c-text-faintest: hsl(330 10% 28%);
|
||||
|
||||
--c-accent: hsl(150 70% 62%);
|
||||
--c-accent-soft: hsl(120 55% 34%);
|
||||
--c-accent-text: hsl(150 75% 80%);
|
||||
--c-accent-2: hsl(60 65% 58%);
|
||||
--c-accent-3: hsl(240 65% 68%);
|
||||
}
|
||||
|
||||
/* base hue 30 (orange): surfaces/borders/text tinted orange; accent
|
||||
complement 210 (blue), accent-soft composite 180 (cyan) */
|
||||
html.orange {
|
||||
--c-surface-0: hsl(30 32% 10%);
|
||||
--c-surface-1: hsl(30 28% 16%);
|
||||
--c-surface-2: hsl(30 26% 22%);
|
||||
|
||||
--c-border: hsl(30 24% 24%);
|
||||
--c-border-soft: hsl(30 22% 33%);
|
||||
--c-border-faint: hsl(30 28% 16%);
|
||||
--c-border-strong: hsl(30 15% 62%);
|
||||
|
||||
--c-text: hsl(30 15% 94%);
|
||||
--c-text-secondary: hsl(30 12% 82%);
|
||||
--c-text-muted: hsl(30 10% 65%);
|
||||
--c-text-subtle: hsl(30 8% 50%);
|
||||
--c-text-faint: hsl(30 8% 38%);
|
||||
--c-text-faintest: hsl(30 10% 28%);
|
||||
|
||||
--c-accent: hsl(210 70% 62%);
|
||||
--c-accent-soft: hsl(180 55% 34%);
|
||||
--c-accent-text: hsl(210 75% 80%);
|
||||
--c-accent-2: hsl(120 65% 55%);
|
||||
--c-accent-3: hsl(300 65% 68%);
|
||||
}
|
||||
|
||||
/* Brighter, light-surfaced counterpart to `green` (ticket 048): same base
|
||||
hue (140) and accent hues as `green`, but with a `light`-style inverted
|
||||
surface/border/text scale (high lightness instead of low) so it reads as
|
||||
a distinctly brighter theme, not a re-tint of the dark `green`. Danger
|
||||
colours are `light`'s (hue-neutral), matching how `light` itself doesn't
|
||||
tint danger either. */
|
||||
html.light_green {
|
||||
--c-surface-0: hsl(140 40% 96%);
|
||||
--c-surface-1: hsl(140 35% 92%);
|
||||
--c-surface-2: hsl(140 30% 85%);
|
||||
|
||||
--c-border: hsl(140 25% 80%);
|
||||
--c-border-soft: hsl(140 22% 70%);
|
||||
--c-border-faint: hsl(140 35% 92%);
|
||||
--c-border-strong: hsl(140 20% 40%);
|
||||
|
||||
--c-text: hsl(140 30% 12%);
|
||||
--c-text-secondary: hsl(140 20% 25%);
|
||||
--c-text-muted: hsl(140 15% 40%);
|
||||
--c-text-subtle: hsl(140 10% 50%);
|
||||
--c-text-faint: hsl(140 10% 65%);
|
||||
--c-text-faintest: hsl(140 12% 80%);
|
||||
|
||||
--c-danger-bg: #fee2e2;
|
||||
--c-danger-border: #fca5a5;
|
||||
--c-danger-text: #b91c1c;
|
||||
--c-danger-text-strong: #991b1b;
|
||||
|
||||
--c-accent: hsl(320 70% 40%);
|
||||
--c-accent-soft: hsl(290 60% 85%);
|
||||
--c-accent-text: hsl(320 75% 28%);
|
||||
--c-accent-2: hsl(230 70% 45%);
|
||||
--c-accent-3: hsl(50 70% 38%);
|
||||
}
|
||||
|
||||
/* Brighter, light-surfaced counterpart to `red` (ticket 056): same base hue
|
||||
(0) and accent hues as `red`, with `light`'s inverted surface/border/text
|
||||
scale and `light`'s hue-neutral danger colours. */
|
||||
html.light_red {
|
||||
--c-surface-0: hsl(0 40% 96%);
|
||||
--c-surface-1: hsl(0 35% 92%);
|
||||
--c-surface-2: hsl(0 30% 85%);
|
||||
|
||||
--c-border: hsl(0 25% 80%);
|
||||
--c-border-soft: hsl(0 22% 70%);
|
||||
--c-border-faint: hsl(0 35% 92%);
|
||||
--c-border-strong: hsl(0 20% 40%);
|
||||
|
||||
--c-text: hsl(0 30% 12%);
|
||||
--c-text-secondary: hsl(0 20% 25%);
|
||||
--c-text-muted: hsl(0 15% 40%);
|
||||
--c-text-subtle: hsl(0 10% 50%);
|
||||
--c-text-faint: hsl(0 10% 65%);
|
||||
--c-text-faintest: hsl(0 12% 80%);
|
||||
|
||||
--c-danger-bg: #fee2e2;
|
||||
--c-danger-border: #fca5a5;
|
||||
--c-danger-text: #b91c1c;
|
||||
--c-danger-text-strong: #991b1b;
|
||||
|
||||
--c-accent: hsl(180 70% 40%);
|
||||
--c-accent-soft: hsl(150 60% 85%);
|
||||
--c-accent-text: hsl(180 75% 28%);
|
||||
--c-accent-2: hsl(90 70% 45%);
|
||||
--c-accent-3: hsl(270 70% 38%);
|
||||
}
|
||||
|
||||
/* Brighter, light-surfaced counterpart to `blue` (ticket 056): same base hue
|
||||
(210) and accent hues as `blue`, with `light`'s inverted surface/border/text
|
||||
scale and `light`'s hue-neutral danger colours. */
|
||||
html.light_blue {
|
||||
--c-surface-0: hsl(210 40% 96%);
|
||||
--c-surface-1: hsl(210 35% 92%);
|
||||
--c-surface-2: hsl(210 30% 85%);
|
||||
|
||||
--c-border: hsl(210 25% 80%);
|
||||
--c-border-soft: hsl(210 22% 70%);
|
||||
--c-border-faint: hsl(210 35% 92%);
|
||||
--c-border-strong: hsl(210 20% 40%);
|
||||
|
||||
--c-text: hsl(210 30% 12%);
|
||||
--c-text-secondary: hsl(210 20% 25%);
|
||||
--c-text-muted: hsl(210 15% 40%);
|
||||
--c-text-subtle: hsl(210 10% 50%);
|
||||
--c-text-faint: hsl(210 10% 65%);
|
||||
--c-text-faintest: hsl(210 12% 80%);
|
||||
|
||||
--c-danger-bg: #fee2e2;
|
||||
--c-danger-border: #fca5a5;
|
||||
--c-danger-text: #b91c1c;
|
||||
--c-danger-text-strong: #991b1b;
|
||||
|
||||
--c-accent: hsl(30 70% 40%);
|
||||
--c-accent-soft: hsl(0 60% 85%);
|
||||
--c-accent-text: hsl(30 75% 28%);
|
||||
--c-accent-2: hsl(300 70% 45%);
|
||||
--c-accent-3: hsl(120 70% 38%);
|
||||
}
|
||||
|
||||
/* Brighter, light-surfaced counterpart to `yellow` (ticket 056): same base
|
||||
hue (50) and accent hues as `yellow`, with `light`'s inverted
|
||||
surface/border/text scale and `light`'s hue-neutral danger colours. */
|
||||
html.light_yellow {
|
||||
--c-surface-0: hsl(50 40% 96%);
|
||||
--c-surface-1: hsl(50 35% 92%);
|
||||
--c-surface-2: hsl(50 30% 85%);
|
||||
|
||||
--c-border: hsl(50 25% 80%);
|
||||
--c-border-soft: hsl(50 22% 70%);
|
||||
--c-border-faint: hsl(50 35% 92%);
|
||||
--c-border-strong: hsl(50 20% 40%);
|
||||
|
||||
--c-text: hsl(50 30% 12%);
|
||||
--c-text-secondary: hsl(50 20% 25%);
|
||||
--c-text-muted: hsl(50 15% 40%);
|
||||
--c-text-subtle: hsl(50 10% 50%);
|
||||
--c-text-faint: hsl(50 10% 65%);
|
||||
--c-text-faintest: hsl(50 12% 80%);
|
||||
|
||||
--c-danger-bg: #fee2e2;
|
||||
--c-danger-border: #fca5a5;
|
||||
--c-danger-text: #b91c1c;
|
||||
--c-danger-text-strong: #991b1b;
|
||||
|
||||
--c-accent: hsl(230 70% 40%);
|
||||
--c-accent-soft: hsl(200 60% 85%);
|
||||
--c-accent-text: hsl(230 75% 28%);
|
||||
--c-accent-2: hsl(140 70% 45%);
|
||||
--c-accent-3: hsl(320 70% 38%);
|
||||
}
|
||||
|
||||
/* Brighter, light-surfaced counterpart to `pink` (ticket 056): same base hue
|
||||
(330) and accent hues as `pink`, with `light`'s inverted surface/border/text
|
||||
scale and `light`'s hue-neutral danger colours. */
|
||||
html.light_pink {
|
||||
--c-surface-0: hsl(330 40% 96%);
|
||||
--c-surface-1: hsl(330 35% 92%);
|
||||
--c-surface-2: hsl(330 30% 85%);
|
||||
|
||||
--c-border: hsl(330 25% 80%);
|
||||
--c-border-soft: hsl(330 22% 70%);
|
||||
--c-border-faint: hsl(330 35% 92%);
|
||||
--c-border-strong: hsl(330 20% 40%);
|
||||
|
||||
--c-text: hsl(330 30% 12%);
|
||||
--c-text-secondary: hsl(330 20% 25%);
|
||||
--c-text-muted: hsl(330 15% 40%);
|
||||
--c-text-subtle: hsl(330 10% 50%);
|
||||
--c-text-faint: hsl(330 10% 65%);
|
||||
--c-text-faintest: hsl(330 12% 80%);
|
||||
|
||||
--c-danger-bg: #fee2e2;
|
||||
--c-danger-border: #fca5a5;
|
||||
--c-danger-text: #b91c1c;
|
||||
--c-danger-text-strong: #991b1b;
|
||||
|
||||
--c-accent: hsl(150 70% 40%);
|
||||
--c-accent-soft: hsl(120 60% 85%);
|
||||
--c-accent-text: hsl(150 75% 28%);
|
||||
--c-accent-2: hsl(60 70% 45%);
|
||||
--c-accent-3: hsl(240 70% 38%);
|
||||
}
|
||||
|
||||
/* Brighter, light-surfaced counterpart to `orange` (ticket 056): same base
|
||||
hue (30) and accent hues as `orange`, with `light`'s inverted
|
||||
surface/border/text scale and `light`'s hue-neutral danger colours. */
|
||||
html.light_orange {
|
||||
--c-surface-0: hsl(30 40% 96%);
|
||||
--c-surface-1: hsl(30 35% 92%);
|
||||
--c-surface-2: hsl(30 30% 85%);
|
||||
|
||||
--c-border: hsl(30 25% 80%);
|
||||
--c-border-soft: hsl(30 22% 70%);
|
||||
--c-border-faint: hsl(30 35% 92%);
|
||||
--c-border-strong: hsl(30 20% 40%);
|
||||
|
||||
--c-text: hsl(30 30% 12%);
|
||||
--c-text-secondary: hsl(30 20% 25%);
|
||||
--c-text-muted: hsl(30 15% 40%);
|
||||
--c-text-subtle: hsl(30 10% 50%);
|
||||
--c-text-faint: hsl(30 10% 65%);
|
||||
--c-text-faintest: hsl(30 12% 80%);
|
||||
|
||||
--c-danger-bg: #fee2e2;
|
||||
--c-danger-border: #fca5a5;
|
||||
--c-danger-text: #b91c1c;
|
||||
--c-danger-text-strong: #991b1b;
|
||||
|
||||
--c-accent: hsl(210 70% 40%);
|
||||
--c-accent-soft: hsl(180 60% 85%);
|
||||
--c-accent-text: hsl(210 75% 28%);
|
||||
--c-accent-2: hsl(120 70% 45%);
|
||||
--c-accent-3: hsl(300 70% 38%);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-surface-0: var(--c-surface-0);
|
||||
--color-surface-1: var(--c-surface-1);
|
||||
|
||||
+10
-49
@@ -84,45 +84,20 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type ColorScheme =
|
||||
| 'light'
|
||||
| 'dark'
|
||||
| 'system'
|
||||
| 'red'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'yellow'
|
||||
| 'pink'
|
||||
| 'orange'
|
||||
| 'light_green'
|
||||
| 'light_red'
|
||||
| 'light_blue'
|
||||
| 'light_yellow'
|
||||
| 'light_pink'
|
||||
| 'light_orange'
|
||||
/** Name of a theme embedded in this build (see `GET /api/themes`), or `"system"` to follow the OS light/dark preference. */
|
||||
export type Theme = string
|
||||
|
||||
export interface Settings {
|
||||
color_scheme: ColorScheme
|
||||
theme: Theme
|
||||
active_profile: string | null
|
||||
/** Maximum ingested logs kept in memory; `null` means unlimited. */
|
||||
max_logs: number | null
|
||||
/** Id of the selected custom theme entry, or `null` if `color_scheme` alone applies. */
|
||||
custom_theme_id: string | null
|
||||
/** Ntfy topic every alert rule delivers to; `null` until configured. */
|
||||
ntfy_topic: string | null
|
||||
/** Ntfy server override; `null` means `https://ntfy.sh`. */
|
||||
ntfy_server: string | null
|
||||
}
|
||||
|
||||
export type CssEntryKind = 'imported' | 'linked'
|
||||
|
||||
export interface CssEntry {
|
||||
id: string
|
||||
kind: CssEntryKind
|
||||
filename: string | null
|
||||
path_or_url: string | null
|
||||
}
|
||||
|
||||
export interface ProfileSummary {
|
||||
name: string
|
||||
param_count: number
|
||||
@@ -352,42 +327,28 @@ export function fetchSettings(): Promise<Settings> {
|
||||
}
|
||||
|
||||
export function updateSettings(update: {
|
||||
colorScheme?: ColorScheme
|
||||
theme?: Theme
|
||||
/** Omit to leave unchanged; `null` clears the cap back to unlimited. */
|
||||
maxLogs?: number | null
|
||||
/** Omit to leave unchanged; `null` clears the selection (falls back to `colorScheme`). */
|
||||
customThemeId?: string | null
|
||||
/** Omit to leave unchanged; `null` clears the configured topic. */
|
||||
ntfyTopic?: string | null
|
||||
/** Omit to leave unchanged; `null` falls back to `https://ntfy.sh`. */
|
||||
ntfyServer?: string | null
|
||||
}): Promise<Settings> {
|
||||
const body: Record<string, unknown> = { color_scheme: update.colorScheme }
|
||||
const body: Record<string, unknown> = { theme: update.theme }
|
||||
if ('maxLogs' in update) body.max_logs = update.maxLogs
|
||||
if ('customThemeId' in update) body.custom_theme_id = update.customThemeId
|
||||
if ('ntfyTopic' in update) body.ntfy_topic = update.ntfyTopic
|
||||
if ('ntfyServer' in update) body.ntfy_server = update.ntfyServer
|
||||
return sendJson('/api/settings', 'PATCH', body)
|
||||
}
|
||||
|
||||
export function fetchCssEntries(): Promise<CssEntry[]> {
|
||||
return getJson('/api/settings/css')
|
||||
/** Names of the themes embedded in this build (see `themes/` at the repo root). */
|
||||
export function fetchThemes(): Promise<string[]> {
|
||||
return getJson('/api/themes')
|
||||
}
|
||||
|
||||
export function importCss(filename: string, content: string): Promise<CssEntry> {
|
||||
return sendJson('/api/settings/css', 'POST', { filename, content })
|
||||
}
|
||||
|
||||
export function linkCss(pathOrUrl: string): Promise<CssEntry> {
|
||||
return sendJson('/api/settings/css/link', 'POST', { path_or_url: pathOrUrl })
|
||||
}
|
||||
|
||||
export function deleteCssEntry(id: string): Promise<void> {
|
||||
return sendJson(`/api/settings/css/${encodeURIComponent(id)}`, 'DELETE')
|
||||
}
|
||||
|
||||
export function cssContentUrl(id: string): string {
|
||||
return `/api/settings/css/${encodeURIComponent(id)}/content`
|
||||
export function themeCssUrl(name: string): string {
|
||||
return `/api/themes/${encodeURIComponent(name)}`
|
||||
}
|
||||
|
||||
export function fetchProfiles(): Promise<ProfileSummary[]> {
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
// A user-supplied style is any CSS file that defines every variable in `THEME_CSS_VARS` somewhere in
|
||||
// a `:root { --c-*: ...; }` block (ticket 020/047: styles are selectable on equal footing with the
|
||||
// built-ins, and there's no requirement that this block be the *only* thing the file contains — a
|
||||
// style is free to also target any other selector, e.g. to restyle layout, spacing, or fonts, not
|
||||
// just the palette). `useSettingsPanel` applies the selected one as an unscoped stylesheet only
|
||||
// while no built-in `html.<scheme>` class is present, so it wins the cascade without rewriting the
|
||||
// selector.
|
||||
|
||||
/** Canonical list of every `--c-*` variable every theme in `index.css` defines. */
|
||||
export const THEME_CSS_VARS = [
|
||||
'--c-surface-0',
|
||||
'--c-surface-1',
|
||||
'--c-surface-2',
|
||||
'--c-border',
|
||||
'--c-border-soft',
|
||||
'--c-border-faint',
|
||||
'--c-border-strong',
|
||||
'--c-text',
|
||||
'--c-text-secondary',
|
||||
'--c-text-muted',
|
||||
'--c-text-subtle',
|
||||
'--c-text-faint',
|
||||
'--c-text-faintest',
|
||||
'--c-danger-bg',
|
||||
'--c-danger-border',
|
||||
'--c-danger-text',
|
||||
'--c-danger-text-strong',
|
||||
'--c-accent',
|
||||
'--c-accent-soft',
|
||||
'--c-accent-text',
|
||||
'--c-accent-2',
|
||||
'--c-accent-3',
|
||||
] as const
|
||||
|
||||
export type ThemeVars = Record<(typeof THEME_CSS_VARS)[number], string>
|
||||
|
||||
// Fully-resolved variable values for each built-in theme, including inherited fallbacks, so a downloaded file is always complete; keep in sync with `index.css` by hand, there's no build step linking the two.
|
||||
export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
||||
dark: {
|
||||
'--c-surface-0': '#0a0a0a',
|
||||
'--c-surface-1': '#171717',
|
||||
'--c-surface-2': '#262626',
|
||||
'--c-border': '#262626',
|
||||
'--c-border-soft': '#404040',
|
||||
'--c-border-faint': '#171717',
|
||||
'--c-border-strong': '#a3a3a3',
|
||||
'--c-text': '#f5f5f5',
|
||||
'--c-text-secondary': '#d4d4d4',
|
||||
'--c-text-muted': '#a3a3a3',
|
||||
'--c-text-subtle': '#737373',
|
||||
'--c-text-faint': '#525252',
|
||||
'--c-text-faintest': '#404040',
|
||||
'--c-danger-bg': '#450a0a',
|
||||
'--c-danger-border': '#7f1d1d',
|
||||
'--c-danger-text': '#f87171',
|
||||
'--c-danger-text-strong': '#fca5a5',
|
||||
'--c-accent': '#0ea5e9',
|
||||
'--c-accent-soft': '#0369a1',
|
||||
'--c-accent-text': '#7dd3fc',
|
||||
'--c-accent-2': '#a78bfa',
|
||||
'--c-accent-3': '#4ade80',
|
||||
},
|
||||
light: {
|
||||
'--c-surface-0': '#fafafa',
|
||||
'--c-surface-1': '#f5f5f5',
|
||||
'--c-surface-2': '#e5e5e5',
|
||||
'--c-border': '#e5e5e5',
|
||||
'--c-border-soft': '#d4d4d4',
|
||||
'--c-border-faint': '#f5f5f5',
|
||||
'--c-border-strong': '#525252',
|
||||
'--c-text': '#171717',
|
||||
'--c-text-secondary': '#404040',
|
||||
'--c-text-muted': '#525252',
|
||||
'--c-text-subtle': '#737373',
|
||||
'--c-text-faint': '#a3a3a3',
|
||||
'--c-text-faintest': '#d4d4d4',
|
||||
'--c-danger-bg': '#fee2e2',
|
||||
'--c-danger-border': '#fca5a5',
|
||||
'--c-danger-text': '#b91c1c',
|
||||
'--c-danger-text-strong': '#991b1b',
|
||||
'--c-accent': '#0284c7',
|
||||
'--c-accent-soft': '#7dd3fc',
|
||||
'--c-accent-text': '#0369a1',
|
||||
'--c-accent-2': '#7c3aed',
|
||||
'--c-accent-3': '#16a34a',
|
||||
},
|
||||
red: {
|
||||
'--c-surface-0': 'hsl(0 32% 10%)',
|
||||
'--c-surface-1': 'hsl(0 28% 16%)',
|
||||
'--c-surface-2': 'hsl(0 26% 22%)',
|
||||
'--c-border': 'hsl(0 24% 24%)',
|
||||
'--c-border-soft': 'hsl(0 22% 33%)',
|
||||
'--c-border-faint': 'hsl(0 28% 16%)',
|
||||
'--c-border-strong': 'hsl(0 15% 62%)',
|
||||
'--c-text': 'hsl(0 15% 94%)',
|
||||
'--c-text-secondary': 'hsl(0 12% 82%)',
|
||||
'--c-text-muted': 'hsl(0 10% 65%)',
|
||||
'--c-text-subtle': 'hsl(0 8% 50%)',
|
||||
'--c-text-faint': 'hsl(0 8% 38%)',
|
||||
'--c-text-faintest': 'hsl(0 10% 28%)',
|
||||
'--c-danger-bg': '#450a0a',
|
||||
'--c-danger-border': '#7f1d1d',
|
||||
'--c-danger-text': '#f87171',
|
||||
'--c-danger-text-strong': '#fca5a5',
|
||||
'--c-accent': 'hsl(180 70% 62%)',
|
||||
'--c-accent-soft': 'hsl(150 55% 34%)',
|
||||
'--c-accent-text': 'hsl(180 75% 80%)',
|
||||
'--c-accent-2': 'hsl(90 65% 58%)',
|
||||
'--c-accent-3': 'hsl(270 65% 68%)',
|
||||
},
|
||||
green: {
|
||||
'--c-surface-0': 'hsl(140 32% 10%)',
|
||||
'--c-surface-1': 'hsl(140 28% 16%)',
|
||||
'--c-surface-2': 'hsl(140 26% 22%)',
|
||||
'--c-border': 'hsl(140 24% 24%)',
|
||||
'--c-border-soft': 'hsl(140 22% 33%)',
|
||||
'--c-border-faint': 'hsl(140 28% 16%)',
|
||||
'--c-border-strong': 'hsl(140 15% 62%)',
|
||||
'--c-text': 'hsl(140 15% 94%)',
|
||||
'--c-text-secondary': 'hsl(140 12% 82%)',
|
||||
'--c-text-muted': 'hsl(140 10% 65%)',
|
||||
'--c-text-subtle': 'hsl(140 8% 50%)',
|
||||
'--c-text-faint': 'hsl(140 8% 38%)',
|
||||
'--c-text-faintest': 'hsl(140 10% 28%)',
|
||||
'--c-danger-bg': '#450a0a',
|
||||
'--c-danger-border': '#7f1d1d',
|
||||
'--c-danger-text': '#f87171',
|
||||
'--c-danger-text-strong': '#fca5a5',
|
||||
'--c-accent': 'hsl(320 70% 62%)',
|
||||
'--c-accent-soft': 'hsl(290 55% 34%)',
|
||||
'--c-accent-text': 'hsl(320 75% 80%)',
|
||||
'--c-accent-2': 'hsl(230 65% 62%)',
|
||||
'--c-accent-3': 'hsl(50 65% 58%)',
|
||||
},
|
||||
blue: {
|
||||
'--c-surface-0': 'hsl(210 32% 10%)',
|
||||
'--c-surface-1': 'hsl(210 28% 16%)',
|
||||
'--c-surface-2': 'hsl(210 26% 22%)',
|
||||
'--c-border': 'hsl(210 24% 24%)',
|
||||
'--c-border-soft': 'hsl(210 22% 33%)',
|
||||
'--c-border-faint': 'hsl(210 28% 16%)',
|
||||
'--c-border-strong': 'hsl(210 15% 62%)',
|
||||
'--c-text': 'hsl(210 15% 94%)',
|
||||
'--c-text-secondary': 'hsl(210 12% 82%)',
|
||||
'--c-text-muted': 'hsl(210 10% 65%)',
|
||||
'--c-text-subtle': 'hsl(210 8% 50%)',
|
||||
'--c-text-faint': 'hsl(210 8% 38%)',
|
||||
'--c-text-faintest': 'hsl(210 10% 28%)',
|
||||
'--c-danger-bg': '#450a0a',
|
||||
'--c-danger-border': '#7f1d1d',
|
||||
'--c-danger-text': '#f87171',
|
||||
'--c-danger-text-strong': '#fca5a5',
|
||||
'--c-accent': 'hsl(30 70% 62%)',
|
||||
'--c-accent-soft': 'hsl(0 55% 34%)',
|
||||
'--c-accent-text': 'hsl(30 75% 80%)',
|
||||
'--c-accent-2': 'hsl(300 65% 68%)',
|
||||
'--c-accent-3': 'hsl(120 65% 55%)',
|
||||
},
|
||||
yellow: {
|
||||
'--c-surface-0': 'hsl(50 32% 10%)',
|
||||
'--c-surface-1': 'hsl(50 28% 16%)',
|
||||
'--c-surface-2': 'hsl(50 26% 22%)',
|
||||
'--c-border': 'hsl(50 24% 24%)',
|
||||
'--c-border-soft': 'hsl(50 22% 33%)',
|
||||
'--c-border-faint': 'hsl(50 28% 16%)',
|
||||
'--c-border-strong': 'hsl(50 15% 62%)',
|
||||
'--c-text': 'hsl(50 15% 94%)',
|
||||
'--c-text-secondary': 'hsl(50 12% 82%)',
|
||||
'--c-text-muted': 'hsl(50 10% 65%)',
|
||||
'--c-text-subtle': 'hsl(50 8% 50%)',
|
||||
'--c-text-faint': 'hsl(50 8% 38%)',
|
||||
'--c-text-faintest': 'hsl(50 10% 28%)',
|
||||
'--c-danger-bg': '#450a0a',
|
||||
'--c-danger-border': '#7f1d1d',
|
||||
'--c-danger-text': '#f87171',
|
||||
'--c-danger-text-strong': '#fca5a5',
|
||||
'--c-accent': 'hsl(230 70% 62%)',
|
||||
'--c-accent-soft': 'hsl(200 55% 34%)',
|
||||
'--c-accent-text': 'hsl(230 75% 80%)',
|
||||
'--c-accent-2': 'hsl(140 65% 55%)',
|
||||
'--c-accent-3': 'hsl(320 65% 68%)',
|
||||
},
|
||||
pink: {
|
||||
'--c-surface-0': 'hsl(330 32% 10%)',
|
||||
'--c-surface-1': 'hsl(330 28% 16%)',
|
||||
'--c-surface-2': 'hsl(330 26% 22%)',
|
||||
'--c-border': 'hsl(330 24% 24%)',
|
||||
'--c-border-soft': 'hsl(330 22% 33%)',
|
||||
'--c-border-faint': 'hsl(330 28% 16%)',
|
||||
'--c-border-strong': 'hsl(330 15% 62%)',
|
||||
'--c-text': 'hsl(330 15% 94%)',
|
||||
'--c-text-secondary': 'hsl(330 12% 82%)',
|
||||
'--c-text-muted': 'hsl(330 10% 65%)',
|
||||
'--c-text-subtle': 'hsl(330 8% 50%)',
|
||||
'--c-text-faint': 'hsl(330 8% 38%)',
|
||||
'--c-text-faintest': 'hsl(330 10% 28%)',
|
||||
'--c-danger-bg': '#450a0a',
|
||||
'--c-danger-border': '#7f1d1d',
|
||||
'--c-danger-text': '#f87171',
|
||||
'--c-danger-text-strong': '#fca5a5',
|
||||
'--c-accent': 'hsl(150 70% 62%)',
|
||||
'--c-accent-soft': 'hsl(120 55% 34%)',
|
||||
'--c-accent-text': 'hsl(150 75% 80%)',
|
||||
'--c-accent-2': 'hsl(60 65% 58%)',
|
||||
'--c-accent-3': 'hsl(240 65% 68%)',
|
||||
},
|
||||
orange: {
|
||||
'--c-surface-0': 'hsl(30 32% 10%)',
|
||||
'--c-surface-1': 'hsl(30 28% 16%)',
|
||||
'--c-surface-2': 'hsl(30 26% 22%)',
|
||||
'--c-border': 'hsl(30 24% 24%)',
|
||||
'--c-border-soft': 'hsl(30 22% 33%)',
|
||||
'--c-border-faint': 'hsl(30 28% 16%)',
|
||||
'--c-border-strong': 'hsl(30 15% 62%)',
|
||||
'--c-text': 'hsl(30 15% 94%)',
|
||||
'--c-text-secondary': 'hsl(30 12% 82%)',
|
||||
'--c-text-muted': 'hsl(30 10% 65%)',
|
||||
'--c-text-subtle': 'hsl(30 8% 50%)',
|
||||
'--c-text-faint': 'hsl(30 8% 38%)',
|
||||
'--c-text-faintest': 'hsl(30 10% 28%)',
|
||||
'--c-danger-bg': '#450a0a',
|
||||
'--c-danger-border': '#7f1d1d',
|
||||
'--c-danger-text': '#f87171',
|
||||
'--c-danger-text-strong': '#fca5a5',
|
||||
'--c-accent': 'hsl(210 70% 62%)',
|
||||
'--c-accent-soft': 'hsl(180 55% 34%)',
|
||||
'--c-accent-text': 'hsl(210 75% 80%)',
|
||||
'--c-accent-2': 'hsl(120 65% 55%)',
|
||||
'--c-accent-3': 'hsl(300 65% 68%)',
|
||||
},
|
||||
light_green: {
|
||||
'--c-surface-0': 'hsl(140 40% 96%)',
|
||||
'--c-surface-1': 'hsl(140 35% 92%)',
|
||||
'--c-surface-2': 'hsl(140 30% 85%)',
|
||||
'--c-border': 'hsl(140 25% 80%)',
|
||||
'--c-border-soft': 'hsl(140 22% 70%)',
|
||||
'--c-border-faint': 'hsl(140 35% 92%)',
|
||||
'--c-border-strong': 'hsl(140 20% 40%)',
|
||||
'--c-text': 'hsl(140 30% 12%)',
|
||||
'--c-text-secondary': 'hsl(140 20% 25%)',
|
||||
'--c-text-muted': 'hsl(140 15% 40%)',
|
||||
'--c-text-subtle': 'hsl(140 10% 50%)',
|
||||
'--c-text-faint': 'hsl(140 10% 65%)',
|
||||
'--c-text-faintest': 'hsl(140 12% 80%)',
|
||||
'--c-danger-bg': '#fee2e2',
|
||||
'--c-danger-border': '#fca5a5',
|
||||
'--c-danger-text': '#b91c1c',
|
||||
'--c-danger-text-strong': '#991b1b',
|
||||
'--c-accent': 'hsl(320 70% 40%)',
|
||||
'--c-accent-soft': 'hsl(290 60% 85%)',
|
||||
'--c-accent-text': 'hsl(320 75% 28%)',
|
||||
'--c-accent-2': 'hsl(230 70% 45%)',
|
||||
'--c-accent-3': 'hsl(50 70% 38%)',
|
||||
},
|
||||
light_red: {
|
||||
'--c-surface-0': 'hsl(0 40% 96%)',
|
||||
'--c-surface-1': 'hsl(0 35% 92%)',
|
||||
'--c-surface-2': 'hsl(0 30% 85%)',
|
||||
'--c-border': 'hsl(0 25% 80%)',
|
||||
'--c-border-soft': 'hsl(0 22% 70%)',
|
||||
'--c-border-faint': 'hsl(0 35% 92%)',
|
||||
'--c-border-strong': 'hsl(0 20% 40%)',
|
||||
'--c-text': 'hsl(0 30% 12%)',
|
||||
'--c-text-secondary': 'hsl(0 20% 25%)',
|
||||
'--c-text-muted': 'hsl(0 15% 40%)',
|
||||
'--c-text-subtle': 'hsl(0 10% 50%)',
|
||||
'--c-text-faint': 'hsl(0 10% 65%)',
|
||||
'--c-text-faintest': 'hsl(0 12% 80%)',
|
||||
'--c-danger-bg': '#fee2e2',
|
||||
'--c-danger-border': '#fca5a5',
|
||||
'--c-danger-text': '#b91c1c',
|
||||
'--c-danger-text-strong': '#991b1b',
|
||||
'--c-accent': 'hsl(180 70% 40%)',
|
||||
'--c-accent-soft': 'hsl(150 60% 85%)',
|
||||
'--c-accent-text': 'hsl(180 75% 28%)',
|
||||
'--c-accent-2': 'hsl(90 70% 45%)',
|
||||
'--c-accent-3': 'hsl(270 70% 38%)',
|
||||
},
|
||||
light_blue: {
|
||||
'--c-surface-0': 'hsl(210 40% 96%)',
|
||||
'--c-surface-1': 'hsl(210 35% 92%)',
|
||||
'--c-surface-2': 'hsl(210 30% 85%)',
|
||||
'--c-border': 'hsl(210 25% 80%)',
|
||||
'--c-border-soft': 'hsl(210 22% 70%)',
|
||||
'--c-border-faint': 'hsl(210 35% 92%)',
|
||||
'--c-border-strong': 'hsl(210 20% 40%)',
|
||||
'--c-text': 'hsl(210 30% 12%)',
|
||||
'--c-text-secondary': 'hsl(210 20% 25%)',
|
||||
'--c-text-muted': 'hsl(210 15% 40%)',
|
||||
'--c-text-subtle': 'hsl(210 10% 50%)',
|
||||
'--c-text-faint': 'hsl(210 10% 65%)',
|
||||
'--c-text-faintest': 'hsl(210 12% 80%)',
|
||||
'--c-danger-bg': '#fee2e2',
|
||||
'--c-danger-border': '#fca5a5',
|
||||
'--c-danger-text': '#b91c1c',
|
||||
'--c-danger-text-strong': '#991b1b',
|
||||
'--c-accent': 'hsl(30 70% 40%)',
|
||||
'--c-accent-soft': 'hsl(0 60% 85%)',
|
||||
'--c-accent-text': 'hsl(30 75% 28%)',
|
||||
'--c-accent-2': 'hsl(300 70% 45%)',
|
||||
'--c-accent-3': 'hsl(120 70% 38%)',
|
||||
},
|
||||
light_yellow: {
|
||||
'--c-surface-0': 'hsl(50 40% 96%)',
|
||||
'--c-surface-1': 'hsl(50 35% 92%)',
|
||||
'--c-surface-2': 'hsl(50 30% 85%)',
|
||||
'--c-border': 'hsl(50 25% 80%)',
|
||||
'--c-border-soft': 'hsl(50 22% 70%)',
|
||||
'--c-border-faint': 'hsl(50 35% 92%)',
|
||||
'--c-border-strong': 'hsl(50 20% 40%)',
|
||||
'--c-text': 'hsl(50 30% 12%)',
|
||||
'--c-text-secondary': 'hsl(50 20% 25%)',
|
||||
'--c-text-muted': 'hsl(50 15% 40%)',
|
||||
'--c-text-subtle': 'hsl(50 10% 50%)',
|
||||
'--c-text-faint': 'hsl(50 10% 65%)',
|
||||
'--c-text-faintest': 'hsl(50 12% 80%)',
|
||||
'--c-danger-bg': '#fee2e2',
|
||||
'--c-danger-border': '#fca5a5',
|
||||
'--c-danger-text': '#b91c1c',
|
||||
'--c-danger-text-strong': '#991b1b',
|
||||
'--c-accent': 'hsl(230 70% 40%)',
|
||||
'--c-accent-soft': 'hsl(200 60% 85%)',
|
||||
'--c-accent-text': 'hsl(230 75% 28%)',
|
||||
'--c-accent-2': 'hsl(140 70% 45%)',
|
||||
'--c-accent-3': 'hsl(320 70% 38%)',
|
||||
},
|
||||
light_pink: {
|
||||
'--c-surface-0': 'hsl(330 40% 96%)',
|
||||
'--c-surface-1': 'hsl(330 35% 92%)',
|
||||
'--c-surface-2': 'hsl(330 30% 85%)',
|
||||
'--c-border': 'hsl(330 25% 80%)',
|
||||
'--c-border-soft': 'hsl(330 22% 70%)',
|
||||
'--c-border-faint': 'hsl(330 35% 92%)',
|
||||
'--c-border-strong': 'hsl(330 20% 40%)',
|
||||
'--c-text': 'hsl(330 30% 12%)',
|
||||
'--c-text-secondary': 'hsl(330 20% 25%)',
|
||||
'--c-text-muted': 'hsl(330 15% 40%)',
|
||||
'--c-text-subtle': 'hsl(330 10% 50%)',
|
||||
'--c-text-faint': 'hsl(330 10% 65%)',
|
||||
'--c-text-faintest': 'hsl(330 12% 80%)',
|
||||
'--c-danger-bg': '#fee2e2',
|
||||
'--c-danger-border': '#fca5a5',
|
||||
'--c-danger-text': '#b91c1c',
|
||||
'--c-danger-text-strong': '#991b1b',
|
||||
'--c-accent': 'hsl(150 70% 40%)',
|
||||
'--c-accent-soft': 'hsl(120 60% 85%)',
|
||||
'--c-accent-text': 'hsl(150 75% 28%)',
|
||||
'--c-accent-2': 'hsl(60 70% 45%)',
|
||||
'--c-accent-3': 'hsl(240 70% 38%)',
|
||||
},
|
||||
light_orange: {
|
||||
'--c-surface-0': 'hsl(30 40% 96%)',
|
||||
'--c-surface-1': 'hsl(30 35% 92%)',
|
||||
'--c-surface-2': 'hsl(30 30% 85%)',
|
||||
'--c-border': 'hsl(30 25% 80%)',
|
||||
'--c-border-soft': 'hsl(30 22% 70%)',
|
||||
'--c-border-faint': 'hsl(30 35% 92%)',
|
||||
'--c-border-strong': 'hsl(30 20% 40%)',
|
||||
'--c-text': 'hsl(30 30% 12%)',
|
||||
'--c-text-secondary': 'hsl(30 20% 25%)',
|
||||
'--c-text-muted': 'hsl(30 15% 40%)',
|
||||
'--c-text-subtle': 'hsl(30 10% 50%)',
|
||||
'--c-text-faint': 'hsl(30 10% 65%)',
|
||||
'--c-text-faintest': 'hsl(30 12% 80%)',
|
||||
'--c-danger-bg': '#fee2e2',
|
||||
'--c-danger-border': '#fca5a5',
|
||||
'--c-danger-text': '#b91c1c',
|
||||
'--c-danger-text-strong': '#991b1b',
|
||||
'--c-accent': 'hsl(210 70% 40%)',
|
||||
'--c-accent-soft': 'hsl(180 60% 85%)',
|
||||
'--c-accent-text': 'hsl(210 75% 28%)',
|
||||
'--c-accent-2': 'hsl(120 70% 45%)',
|
||||
'--c-accent-3': 'hsl(300 70% 38%)',
|
||||
},
|
||||
}
|
||||
|
||||
export function generateThemeCss(vars: ThemeVars): string {
|
||||
const lines = THEME_CSS_VARS.map((name) => ` ${name}: ${vars[name]};`)
|
||||
return `:root {\n${lines.join('\n')}\n}\n`
|
||||
}
|
||||
|
||||
export interface ParsedTheme {
|
||||
vars: ThemeVars
|
||||
missing: string[]
|
||||
}
|
||||
|
||||
// Parses `--c-*: value;` declarations from the first `:root { ... }` block (or the whole file, tolerant of a dropped selector); reports missing canonical variables so callers can reject an incomplete upload.
|
||||
export function parseThemeCss(content: string): ParsedTheme {
|
||||
const rootMatch = content.match(/:root\s*\{([^}]*)\}/)
|
||||
const body = rootMatch ? rootMatch[1] : content
|
||||
const found: Record<string, string> = {}
|
||||
const declRegex = /(--c-[a-z0-9-]+)\s*:\s*([^;]+);/gi
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = declRegex.exec(body))) {
|
||||
found[match[1]] = match[2].trim()
|
||||
}
|
||||
const missing = THEME_CSS_VARS.filter((name) => !(name in found))
|
||||
const vars = Object.fromEntries(THEME_CSS_VARS.map((name) => [name, found[name] ?? ''])) as ThemeVars
|
||||
return { vars, missing }
|
||||
}
|
||||
|
||||
export function downloadThemeFile(filename: string, content: string) {
|
||||
const blob = new Blob([content], { type: 'text/css' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -2,75 +2,49 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
activateProfile,
|
||||
createProfile,
|
||||
cssContentUrl,
|
||||
deleteCssEntry,
|
||||
deleteProfile,
|
||||
fetchCssEntries,
|
||||
fetchProfiles,
|
||||
fetchSettings,
|
||||
importCss,
|
||||
linkCss,
|
||||
fetchThemes,
|
||||
themeCssUrl,
|
||||
updateSettings,
|
||||
type ColorScheme,
|
||||
type CssEntry,
|
||||
type ProfileSummary,
|
||||
type Settings,
|
||||
type Theme,
|
||||
} 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'
|
||||
// `data-*` marker for the single injected `<link>` for the active theme's stylesheet.
|
||||
const INJECTED_THEME_ATTR = 'data-ottyl-theme-css'
|
||||
|
||||
// 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
|
||||
// `"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'
|
||||
|
||||
// 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
|
||||
}
|
||||
function resolveTheme(theme: Theme): string {
|
||||
if (theme !== 'system') return theme
|
||||
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
|
||||
return prefersDark ? SYSTEM_DARK_THEME : SYSTEM_LIGHT_THEME
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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())
|
||||
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)
|
||||
link.href = themeCssUrl(resolved)
|
||||
link.setAttribute(INJECTED_THEME_ATTR, resolved)
|
||||
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.
|
||||
// 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 [cssEntries, setCssEntries] = useState<CssEntry[]>([])
|
||||
const [themes, setThemes] = useState<string[]>([])
|
||||
const [profiles, setProfiles] = useState<ProfileSummary[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -80,9 +54,9 @@ export function useSettingsPanel() {
|
||||
return next
|
||||
}, [])
|
||||
|
||||
const refreshCss = useCallback(async () => {
|
||||
const next = await fetchCssEntries()
|
||||
setCssEntries(next)
|
||||
const refreshThemes = useCallback(async () => {
|
||||
const next = await fetchThemes()
|
||||
setThemes(next)
|
||||
return next
|
||||
}, [])
|
||||
|
||||
@@ -94,21 +68,20 @@ export function useSettingsPanel() {
|
||||
|
||||
useEffect(() => {
|
||||
refreshSettings().catch(() => {})
|
||||
refreshCss().catch(() => {})
|
||||
refreshThemes().catch(() => {})
|
||||
refreshProfiles().catch(() => {})
|
||||
}, [refreshSettings, refreshCss, refreshProfiles])
|
||||
|
||||
const activeCustomTheme = settings?.custom_theme_id
|
||||
? cssEntries.find((entry) => entry.id === settings.custom_theme_id) ?? null
|
||||
: null
|
||||
}, [refreshSettings, refreshThemes, refreshProfiles])
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) applyColorScheme(settings.color_scheme, activeCustomTheme !== null)
|
||||
}, [settings, activeCustomTheme])
|
||||
|
||||
useEffect(() => {
|
||||
applyCustomThemeCss(activeCustomTheme)
|
||||
}, [activeCustomTheme])
|
||||
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 () => {
|
||||
@@ -121,20 +94,10 @@ export function useSettingsPanel() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setColorScheme = useCallback(
|
||||
(colorScheme: ColorScheme) =>
|
||||
const setTheme = useCallback(
|
||||
(theme: Theme) =>
|
||||
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 })
|
||||
const next = await updateSettings({ theme })
|
||||
setSettings(next)
|
||||
})(),
|
||||
[guarded],
|
||||
@@ -158,40 +121,6 @@ export function useSettingsPanel() {
|
||||
[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 () => {
|
||||
@@ -221,17 +150,12 @@ export function useSettingsPanel() {
|
||||
|
||||
return {
|
||||
settings,
|
||||
cssEntries,
|
||||
activeCustomTheme,
|
||||
themes,
|
||||
profiles,
|
||||
error,
|
||||
setColorScheme,
|
||||
setCustomTheme,
|
||||
setTheme,
|
||||
setMaxLogs,
|
||||
setNtfySink,
|
||||
importCssEntry,
|
||||
linkCssEntry,
|
||||
removeCssEntry,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
switchProfile,
|
||||
|
||||
Reference in New Issue
Block a user