App shell as the canvas draws it, full lazy Cache tree, styles aligned with the parent projects

Shell: collapsible rail, DNS/Server groups with pins, always-on header with
breadcrumbs and server status, Ctrl/Cmd+K command palette.
Cache: left column is a lazily expanded tree of every zone, right column the
selected node's records; both columns are ReUI Frames.
Styles: dark variant follows data-theme (field and outline fills), 32 px selects,
36 px segmented strip, 40 px caption table heads, 56 px rows, 20/24/32 page padding.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 09:35:41 +07:00
1 parent 44b6dc246c
commit f4513533c4
22 files changed
+509 -534

No files matched your search

+178 -50
View File
@@ -29,6 +29,8 @@ import styles from './Shell.module.css'
import { Icon, type IconName } from '../ui/Icon'
import { publicUrl } from './base'
import { Menu } from '../ui/Menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { ThisServerName } from '../ui/ClusterNodeSelect'
import { FooterLinks } from '../ui/FooterLinks'
import { Versions } from './Versions'
@@ -54,12 +56,29 @@ The sidebar's three groups. It is not a new taxonomy: it is upstream's own order
with a gap where the kind of task already changed. What you operate daily, what you
configure and what you consult.
*/
const GROUPS: string[][] = [
['dashboard', 'zones', 'cache', 'blocking', 'apps', 'dnsclient'],
['settings', 'dhcp', 'admin'],
['logs', 'about'],
const GROUPS: { name: string; ids: string[] }[] = [
{ name: 'DNS', ids: ['dashboard', 'zones', 'cache', 'blocking', 'apps', 'dnsclient'] },
{ name: 'Server', ids: ['settings', 'dhcp', 'admin', 'logs', 'about'] },
]
/* The sidebar's own preferences, per browser, as the canvas keeps them: the rail
and the pinned sections. A browser that refuses storage starts from the defaults. */
function stored<T>(key: string, fallback: T): T {
try {
const v = localStorage.getItem(key)
return v == null ? fallback : (JSON.parse(v) as T)
} catch {
return fallback
}
}
function keep(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
/* nothing to do */
}
}
const ICONS: Record<string, IconName> = {
dashboard: 'dashboard', zones: 'zones', cache: 'cache', blocking: 'blocked',
apps: 'apps', dnsclient: 'dnsclient', settings: 'settings',
@@ -120,6 +139,33 @@ export function Shell({
})
const [active, setActive] = useState(() => initialRoute?.section ?? sections[0]?.id ?? 'about')
const [drawer, setDrawer] = useState(false)
const [rail, setRail] = useState(() => stored<boolean>('shell.rail', false))
const [shut, setShut] = useState<string[]>([])
const [pins, setPins] = useState<string[]>(() => stored<string[]>('shell.pins', []))
const [palette, setPalette] = useState(false)
function toggleRail() {
setRail((v) => {
keep('shell.rail', !v)
return !v
})
}
function togglePin(id: string) {
setPins((v) => {
const next = v.includes(id) ? v.filter((x) => x !== id) : [...v, id]
keep('shell.pins', next)
return next
})
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setPalette((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const [modal, setModal] = useState<ModalId | null>(forcePasswordChange ? 'password' : null)
/* The factory password travels only with the opening the login caused; from the
user menu `showChangePasswordModal()` is called with nothing (main.js). */
@@ -299,12 +345,61 @@ export function Shell({
return () => window.removeEventListener('popstate', onChanged)
}, [sections])
const pinned = sections.filter((sec) => pins.includes(sec.id))
function go(id: string, to: string | null) {
setActive(id)
setSub(to)
setDrawer(false)
setPalette(false)
}
function navRow(sec: (typeof sections)[number], where: 'pin' | 'main') {
const here = sec.id === current?.id
const first = sec.subs?.[0] ?? null
const pin = pins.includes(sec.id)
return (
<div className={styles.nrow} key={`${where}-${sec.id}`}>
<a
className={styles.s}
href={toTrail({ section: sec.id, sub: first })}
data-active={here}
aria-current={here ? 'page' : undefined}
title={sec.id === 'blocking' ? blockingNow?.label : rail ? sec.label : undefined}
onClick={(e) => {
if (!plainClick(e)) return
e.preventDefault()
go(sec.id, first)
}}
>
<span className={styles.ico}>
<Icon name={ICONS[sec.id] ?? 'about'} size={16} />
</span>
<span className={styles.lbl}>{sec.label}</span>
{sec.id === 'blocking' && <BlockingMark mark={blockingNow} />}
</a>
<button
type="button"
className={styles.star}
data-on={pin}
aria-pressed={pin}
aria-label={`${pin ? 'Unpin' : 'Pin'} ${sec.label}`}
onClick={() => togglePin(sec.id)}
>
{pin ? '\u2605' : '\u2606'}
</button>
</div>
)
}
const crumbs = [info?.dnsServerDomain, current?.label, currentSub].filter((x): x is string => !!x)
return (
<HandoffContext.Provider value={handoff}>
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
<SlotProvider>
{(slot) => (
<div className={styles.shell}>
<div className={styles.shell} data-rail={rail}>
{drawer && (
<button
type="button"
@@ -316,8 +411,15 @@ export function Shell({
<aside className={styles.side} data-open={drawer}>
<div className={styles.sbrand}>
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={22} height={22} /> Technitium
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={22} height={22} />
<span className={styles.lbl}>Technitium</span>
<span className={`${styles.lbl} ${styles.sbtag}`}>console</span>
</div>
<button type="button" className={styles.sbsearch} onClick={() => setPalette(true)} aria-label="Search">
<Icon name="menu" size={14} />
<span className={styles.lbl}>Search</span>
<kbd className={styles.lbl}>{'\u2318K'}</kbd>
</button>
<nav className={styles.slist} role="navigation" aria-label="Sections">
{/*
This is a navigation menu, and until now it claimed to be a `tablist`.
@@ -338,47 +440,31 @@ export function Shell({
`aria-current="page"` is reserved for ONE thing: the page you are on,
which is the sub-section when there is one.
*/}
{GROUPS.map((group, g) => (
<div className={styles.group} key={g}>
{sections.filter((sec) => group.includes(sec.id)).map((sec) => {
const active = sec.id === current?.id
const first = sec.subs?.[0] ?? null
return (
<div key={sec.id}>
<a
className={styles.s}
href={toTrail({ section: sec.id, sub: first })}
data-active={active}
aria-current={active ? 'page' : undefined}
title={sec.id === 'blocking' ? blockingNow?.label : undefined}
onClick={(e) => {
if (!plainClick(e)) return
e.preventDefault()
setActive(sec.id)
setSub(first)
setDrawer(false)
}}
>
<span className={styles.ico}>
<Icon name={ICONS[sec.id] ?? 'about'} size={16} />
</span>
{sec.label}
{sec.id === 'blocking' && <BlockingMark mark={blockingNow} />}
</a>
{/*
The sub-items are NOT here any more. The design puts a
section's sub-navigation under its title, for a reason:
nested here, nine panes run out of room the moment the rail
drops to 60 px. What the sidebar gets back is
its own shape: twelve entries, always the same twelve, instead
of fourteen, eighteen or twenty-one depending on where you are.
See `ui/SubTabs`.
*/}
</div>
)
})}
{pinned.length > 0 && (
<div className={styles.group}>
<div className={styles.grph}>Pinned</div>
{pinned.map((sec) => navRow(sec, 'pin'))}
</div>
))}
)}
{GROUPS.map((group) => {
const items = sections.filter((sec) => group.ids.includes(sec.id))
if (items.length === 0) return null
const closed = shut.includes(group.name)
return (
<div className={styles.group} key={group.name}>
<button
type="button"
className={styles.grph}
aria-expanded={!closed}
onClick={() => setShut((v) => (closed ? v.filter((x) => x !== group.name) : [...v, group.name]))}
>
{group.name}
<span className={styles.caret} data-shut={closed}><Icon name="chevronDown" size={12} /></span>
</button>
{!closed && items.map((sec) => navRow(sec, 'main'))}
</div>
)
})}
</nav>
{/* The server and the account, at the foot. When narrow you reach it via the drawer. */}
@@ -475,13 +561,29 @@ export function Shell({
className={styles.hamburger}
aria-label="Menu"
aria-expanded={drawer}
onClick={() => setDrawer((v) => !v)}
onClick={() => (window.matchMedia('(max-width: 900px)').matches ? setDrawer((v) => !v) : toggleRail())}
>
<Icon name="menu" size={18} />
</button>
<span className={styles.topBrand}>
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={22} height={22} /> Technitium
</span>
<div className={styles.crumbs} role="group" aria-label="Breadcrumb">
{crumbs.map((c, i) => (
<span key={i} data-last={i === crumbs.length - 1}>{c}</span>
))}
</div>
<Popover>
<PopoverTrigger className={styles.status} aria-label="Server status">
<i className={styles.pdot} data-tone={blockingNow?.tone ?? 'ok'} />
<span className={styles.lbl}>{info?.dnsServerDomain ?? 'Server'}</span>
</PopoverTrigger>
<PopoverContent align="end" className={styles.pop}>
<dl>
<dt>Server</dt><dd>{info?.dnsServerDomain ?? '-'}</dd>
<dt>Version</dt><dd>{info?.version ?? '-'}</dd>
<dt>Up since</dt><dd>{info?.uptimestamp ? new Date(info.uptimestamp).toLocaleString() : '-'}</dd>
<dt>Blocking</dt><dd>{blockingNow ? blockingNow.label.replace(/^Blocking,?\s*/, '') : 'Enabled'}</dd>
</dl>
</PopoverContent>
</Popover>
</header>
{/* Neither `tabpanel` nor `aria-labelledby`: ever since each section has its
@@ -606,6 +708,32 @@ export function Shell({
</footer>
</div>
<CommandDialog open={palette} onOpenChange={setPalette} title="Search" description="Go to a section or an account action">
<Command>
<CommandInput placeholder="Go to..." />
<CommandList>
<CommandEmpty>Nothing found.</CommandEmpty>
<CommandGroup heading="Sections">
{sections.flatMap((sec) => [
<CommandItem key={sec.id} value={sec.label} onSelect={() => go(sec.id, sec.subs?.[0] ?? null)}>
<Icon name={ICONS[sec.id] ?? 'about'} size={16} /> {sec.label}
</CommandItem>,
...(sec.subs ?? []).map((sb) => (
<CommandItem key={`${sec.id}/${sb}`} value={`${sec.label} ${sb}`} onSelect={() => go(sec.id, sb)}>
<Icon name="chevronRight" size={14} /> {sec.label} / {sb}
</CommandItem>
)),
])}
</CommandGroup>
<CommandGroup heading="Account">
<CommandItem value="My Profile" onSelect={() => { setPalette(false); open('profile') }}>My Profile</CommandItem>
<CommandItem value="Change Theme" onSelect={() => { setPalette(false); open('theme') }}>Change Theme</CommandItem>
<CommandItem value="Logout" onSelect={() => { setPalette(false); onLogout() }}>Logout</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
<MyProfile
open={modal === 'profile'}
onOpenChange={(o) => setModal(o ? 'profile' : null)}