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:
1 parent
44b6dc246c
commit
f4513533c4
22 files changed
+509
-534
No files matched your search
+178
-50
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user