Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: header links menu for swarm-wide services (hyperhive#3289)

Single 🔗 button in the Shell header (direction 1 of 3 proposed on the
issue) — one fixed element regardless of link count, so it can't
clutter as the list grows, and visible on every route since Shell
wraps all of them. Opens a popover listing whatever GET /api/links
returns (icon + label, opens in a new tab); hidden entirely when the
list is empty, same "don't render a dead affordance" rule the old
dashboard's H0M3 tiles follow for Forge/Matrix.

Docs: docs/swarm/ui.md gets a short section on the feature + the
contribute-your-own-entry idiom.

Verified: npm run build + npm run typecheck clean, headless-chromium
screenshots of both the closed and (temporarily forced open for the
screenshot only) open states, pixel-sampled to confirm the popover is
actually using the dark theme vars and not just looking that way in a
downscaled preview.
This commit is contained in:
iris 2026-08-15 13:47:31 +02:00 • committed by mara
commit 8542f2ca42
4 changed files with 161 additions and 0 deletions

View file

@ -0,0 +1,87 @@
// <LinksMenu> — the "accessible from every screen" affordance for
// swarm-wide services (authelia, matrix, forge, this UI's own API
// docs, …): one fixed header button that never grows the header
// itself as the list grows, opening a popover with everything
// `GET /api/links` returns. Lives in `Shell` (not `ui/`) because it
// owns its own fetch — every other `ui/` primitive is presentational
// only, this one genuinely isn't reusable outside this one job.
//
// The list is entirely server-driven (see swarm-controller's
// `services.hyperhive.swarm.controller.links` option) — 0 entries
// hides the button rather than showing an empty popover, same "don't
// render a dead affordance" rule the old dashboard's H0M3 tiles follow
// for Forge/Matrix.
import { useEffect, useRef, useState } from 'preact/hooks';
import './LinksMenu.css';
interface ServiceLink {
label: string;
icon: string;
url: string;
}
export function LinksMenu() {
const [links, setLinks] = useState<ServiceLink[] | null>(null);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetch('/api/links')
.then((r) => (r.ok ? (r.json() as Promise<ServiceLink[]>) : Promise.reject(new Error(`http ${r.status}`))))
.then(setLinks)
.catch(() => setLinks([])); // best-effort: no button beats a broken one
}, []);
// Close on an outside click or Escape — only listens while open, so
// this costs nothing on every other render.
useEffect(() => {
if (!open) return;
function onPointerDown(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
}
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
if (!links || links.length === 0) return null;
return (
<div class="links-menu" ref={rootRef}>
<button
type="button"
class="links-menu-button"
aria-haspopup="true"
aria-expanded={open}
aria-label="swarm services"
onClick={() => setOpen((v) => !v)}
>
🔗
</button>
{open ? (
<div class="links-menu-popover" role="menu">
{links.map((link) => (
<a
key={link.url}
class="links-menu-item"
href={link.url}
target="_blank"
rel="noreferrer"
role="menuitem"
onClick={() => setOpen(false)}
>
{link.icon ? <span aria-hidden="true">{link.icon}</span> : null}
{link.label}
</a>
))}
</div>
) : null}
</div>
);
}