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, 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(null) const panel = useSettingsPanel() const docsNav = useDocsNav() const [docsPanelTarget, setDocsPanelTarget] = useState(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 (
ottyl
{stats ? ( <>
{stats.log_count} logs
seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}
{stats.approx_bytes_total.toLocaleString()} bytes
) : (
connecting…
)}
{openPanel && (

{PANEL_TITLES[openPanel]}

{openPanel === 'settings' && }
{panel.error && openPanel !== 'docs' && (
{panel.error}
)} {openPanel === 'settings' && } {openPanel === 'docs' && }
)}
) } /// 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 }) { 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 (
) } function SettingsPanel({ panel }: { panel: ReturnType }) { const [maxLogsInput, setMaxLogsInput] = useState('') const [cssFilename, setCssFilename] = useState('') const [cssContent, setCssContent] = useState('') const [cssUploadError, setCssUploadError] = useState(null) const [cssLinkUrl, setCssLinkUrl] = useState('') const [ntfyTopicInput, setNtfyTopicInput] = useState('') const [ntfyServerInput, setNtfyServerInput] = useState('') const [ntfyTestError, setNtfyTestError] = useState(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) { 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 (

Colour scheme

{COLOR_SCHEMES.map((scheme) => (
{scheme !== 'system' && ( )}
))}

Log retention

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" /> max logs kept in memory (blank = unlimited)

Ntfy sink

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" /> 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" />
{ntfyTestError &&
{ntfyTestError}
} {ntfyTestSuccess &&
Test notification sent.
}

CSS

imported/linked stylesheets — each one becomes a selectable alternative to the built-in colour schemes above
{panel.cssEntries.length === 0 ? (
No CSS configured.
) : (
    {panel.cssEntries.map((entry) => (
  • {entry.kind}
    {entry.kind === 'imported' ? ( ) : ( — )}
  • ))}
)}
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" />