Files
ottyl/webapp/src/components/NavMenu.tsx
T
Hugh KaznowskiandClaude Sonnet 5 5b31f51526 Unify colour scheme and CSS as one Style list in settings UI (ticket 047)
The settings panel had two separate sections — "Colour scheme" (built-in
palettes) and "CSS" (imported/linked stylesheets) — even though the data
model (tickets 012/020) already treats both as equally-selectable themes
via custom_theme_id. Merge them into one "Style" section presenting a
single list: built-in scheme pills plus imported/linked entries, any of
which can be a palette swap or a full layout override, with copy that no
longer implies "colour scheme" and "CSS" are separate features.

Traced and manually verified (via a running dev server) that a
user-supplied stylesheet is not constrained to --c-* variable overrides:
import_css/get_css_content in src/api.rs and src/settings.rs store and
serve the raw content unmodified (no sanitization or selector scoping),
and applyCustomThemeCss injects it as a plain unscoped <link> tag — a
stylesheet with e.g. `flex-direction`/`font-size` rules on real selectors
applies exactly as authored, alongside the required --c-* declarations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-06 02:17:10 +01:00

503 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import type { Stats } from '../lib/api'
import { cssContentUrl, sendTestAlertNotification, type ColorScheme, type CssEntry } 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'
type PanelName = 'settings' | 'docs' | null
const PANEL_TITLES: Record<Exclude<PanelName, null>, string> = {
settings: 'Settings',
docs: 'Docs',
}
/** 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',
]
/** 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, ' ')
}
export function NavMenu({ stats }: { stats: Stats | null }) {
const [openPanel, setOpenPanel] = useState<PanelName>(null)
const panel = useSettingsPanel()
const docsNav = useDocsNav()
const [docsPanelTarget, setDocsPanelTarget] = useState<DocsTarget | null>(null)
function togglePanel(name: PanelName) {
setOpenPanel((current) => (current === name ? null : name))
}
// A "?" icon always opens Docs to its section and flashes it, even if Docs is already open elsewhere — hence forcing `openPanel` here rather than toggling.
useEffect(() => {
if (!docsNav.target) return
setOpenPanel('docs')
setDocsPanelTarget(docsNav.target)
}, [docsNav.target])
// Opening Docs via this button (not a "?" icon) has no forced section or flash, so clear any pending help-triggered target.
function handleDocsButtonClick() {
togglePanel('docs')
setDocsPanelTarget(null)
}
return (
<div className="flex h-full">
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-border p-3">
<div>
<div className="mb-4 text-lg font-semibold text-text">ottyl</div>
<PresetsDropdown panel={panel} />
<nav className="mt-2 flex flex-col gap-1 text-sm text-text-muted">
<button
type="button"
onClick={() => togglePanel('settings')}
className={`rounded px-2 py-1 text-left hover:bg-surface-2 ${
openPanel === 'settings' ? 'bg-surface-2 text-text' : ''
}`}
>
Settings
</button>
<button
type="button"
onClick={handleDocsButtonClick}
className={`rounded px-2 py-1 text-left hover:bg-surface-2 ${
openPanel === 'docs' ? 'bg-surface-2 text-text' : ''
}`}
>
Docs
</button>
</nav>
</div>
<div className="text-xs text-text-subtle">
{stats ? (
<>
<div>{stats.log_count} logs</div>
<div>
seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}
</div>
<div>{stats.approx_bytes_total.toLocaleString()} bytes</div>
</>
) : (
<div>connecting…</div>
)}
</div>
</div>
{openPanel && (
<div
className={`fixed inset-y-0 left-48 z-20 flex flex-col overflow-y-auto border-r border-border bg-surface-1 p-4 shadow-xl ${
openPanel === 'docs' ? 'w-[32rem]' : 'w-80'
}`}
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<h2 className="text-sm font-semibold text-text">{PANEL_TITLES[openPanel]}</h2>
{openPanel === 'settings' && <HelpIcon sectionId="settings" label="Help: Settings" />}
</div>
<button
type="button"
onClick={() => setOpenPanel(null)}
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-text"
aria-label="Close panel"
>
✕
</button>
</div>
{panel.error && openPanel !== 'docs' && (
<div className="mb-3 rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text-strong">
{panel.error}
</div>
)}
{openPanel === 'settings' && <SettingsPanel panel={panel} />}
{openPanel === 'docs' && <DocsPanel target={docsPanelTarget} />}
</div>
)}
</div>
)
}
/// Replaces the old Presets side-panel tab: a compact dropdown of profiles plus "+" (create a copy of
/// the active profile, prompting for its name) and "×" (delete the active profile — disabled for
/// `Default`, which can never be deleted).
function PresetsDropdown({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
const activeProfile = panel.settings?.active_profile ?? null
async function handleCreate() {
const name = window.prompt('New profile name (copies the current profile):')?.trim()
if (!name) return
await panel.addProfile(name)
await panel.switchProfile(name)
}
async function handleDelete() {
if (!activeProfile || activeProfile === DEFAULT_PROFILE_NAME) return
await panel.removeProfile(activeProfile)
}
return (
<div className="flex items-center gap-1">
<select
value={activeProfile ?? ''}
onChange={(e) => panel.switchProfile(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
>
{activeProfile === null && <option value="">No active profile</option>}
{panel.profiles.map((profile) => (
<option key={profile.name} value={profile.name}>
{profile.name}
</option>
))}
</select>
<button
type="button"
onClick={handleCreate}
className="shrink-0 rounded border border-border-soft px-1.5 py-1 text-xs text-text-secondary hover:bg-surface-2"
aria-label="New profile"
title="New profile (copies the current one)"
>
+
</button>
<button
type="button"
onClick={handleDelete}
disabled={!activeProfile || activeProfile === DEFAULT_PROFILE_NAME}
className="shrink-0 rounded border border-border-soft px-1.5 py-1 text-xs text-text-secondary hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Delete current profile"
title={activeProfile === DEFAULT_PROFILE_NAME ? "The Default profile can't be deleted" : 'Delete current profile'}
>
×
</button>
<HelpIcon sectionId="presets" label="Help: Presets" />
</div>
)
}
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)
const [ntfyTestSuccess, setNtfyTestSuccess] = useState(false)
const [ntfyTesting, setNtfyTesting] = useState(false)
useEffect(() => {
setMaxLogsInput(panel.settings?.max_logs != null ? String(panel.settings.max_logs) : '')
}, [panel.settings?.max_logs])
useEffect(() => {
setNtfyTopicInput(panel.settings?.ntfy_topic ?? '')
setNtfyServerInput(panel.settings?.ntfy_server ?? '')
}, [panel.settings?.ntfy_topic, panel.settings?.ntfy_server])
function handleNtfyTopicBlur() {
panel.setNtfySink(ntfyTopicInput.trim() || null, panel.settings?.ntfy_server ?? null)
}
function handleNtfyServerBlur() {
panel.setNtfySink(panel.settings?.ntfy_topic ?? null, ntfyServerInput.trim() || null)
}
async function handleNtfyTest() {
setNtfyTestError(null)
setNtfyTestSuccess(false)
if (!ntfyTopicInput.trim()) {
setNtfyTestError('Enter a topic to send a test notification.')
return
}
setNtfyTesting(true)
try {
await sendTestAlertNotification(ntfyTopicInput.trim(), ntfyServerInput.trim() || null)
setNtfyTestSuccess(true)
} catch (err) {
setNtfyTestError(err instanceof Error ? err.message : 'Failed to send test notification')
} finally {
setNtfyTesting(false)
}
}
function handleMaxLogsBlur() {
const trimmed = maxLogsInput.trim()
if (trimmed === '') {
panel.setMaxLogs(null)
return
}
const parsed = Number(trimmed)
if (!Number.isFinite(parsed) || parsed <= 0) {
setMaxLogsInput(panel.settings?.max_logs != null ? String(panel.settings.max_logs) : '')
return
}
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>
<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.
</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>
{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>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Log retention</h3>
<div className="flex items-center gap-2">
<input
type="number"
min={1}
placeholder="Unlimited"
value={maxLogsInput}
onChange={(e) => setMaxLogsInput(e.target.value)}
onBlur={handleMaxLogsBlur}
className="w-28 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"
/>
<span className="text-xs text-text-faint">max logs kept in memory (blank = unlimited)</span>
</div>
</section>
<section>
<div className="mb-2 flex items-center gap-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">Ntfy sink</h3>
<HelpIcon sectionId="alerts" label="Help: Ntfy sink" />
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5">
<input
type="text"
placeholder="https://ntfy.sh"
value={ntfyServerInput}
onChange={(e) => setNtfyServerInput(e.target.value)}
onBlur={handleNtfyServerBlur}
className="min-w-0 flex-[2] 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"
/>
<span className="shrink-0 text-xs text-text-faint" aria-hidden="true">/</span>
<input
type="text"
placeholder="topic"
value={ntfyTopicInput}
onChange={(e) => setNtfyTopicInput(e.target.value)}
onBlur={handleNtfyTopicBlur}
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>
{ntfyTestError && <div className="text-xs text-danger-text-strong">{ntfyTestError}</div>}
{ntfyTestSuccess && <div className="text-xs text-text-secondary">Test notification sent.</div>}
<button
type="button"
onClick={handleNtfyTest}
disabled={ntfyTesting}
className="self-start rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2 disabled:opacity-50"
>
{ntfyTesting ? 'Sending…' : 'Send test notification'}
</button>
</div>
</section>
</div>
)
}