Files
ottyl/webapp/src/components/NavMenu.tsx
T
Hugh KaznowskiandClaude Sonnet 5 d6b7f7dfb6 Make imported/linked CSS always a full theme replacement (ticket 020)
Removes the "Use as theme" checkbox/is_theme distinction from
NavMenu.tsx's CSS import and link forms — every imported/linked
stylesheet is now always validated against the canonical --c-*
variable set and becomes a selectable theme, same as today's
is_theme:true path. Drops is_theme entirely from the backend
(CssEntry, ImportCssRequest, LinkCssRequest, CssEntryDto) and from
the frontend CssEntry type; an existing settings.json with a stored
is_theme:false entry still deserializes fine (the field is simply
unknown/ignored) and that entry becomes selectable like any other,
so no migration step is needed. Also drops the now-dead
always-on-overlay CSS injection path from useSettingsPanel.ts, and
changes the CSS filename placeholder from "name.css" to "enter name".

Implemented directly against main rather than merging the dispatched
worktree branch for this ticket, since that branch's base predated
ticket 012 (the custom-themes/imported-CSS consolidation) and its
NavMenu structure no longer matched current main.

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

492 lines
19 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',
]
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 theme (ticket 020), so it's always
// validated against the canonical `--c-*` variable set before it's sent.
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>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Colour scheme</h3>
<div className="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'
}`}
>
{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 ${scheme} theme`}
title="Download as .css"
>
↓
</button>
)}
</div>
))}
</div>
</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>
<section>
<div className="mb-2 flex items-center gap-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">CSS</h3>
<span className="text-[11px] text-text-faint">
imported/linked stylesheets — each one becomes a selectable alternative to the built-in colour schemes
above
</span>
</div>
{panel.cssEntries.length === 0 ? (
<div className="mb-2 text-xs text-text-faint">No CSS configured.</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>
)}
<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>
</div>
)
}