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
492 lines
19 KiB
TypeScript
492 lines
19 KiB
TypeScript
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>
|
||
)
|
||
}
|