Pure `nix fmt` output from the commit before this one — no hand edits. 203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs. Reproduce with `nix develop -c nix fmt` on the parent commit; the result should be byte-identical to this tree. None of the 13 `.prettierignore` entries appears here — verified by intersecting the changed-file list against the ignore file, with a control proving the intersection finds a match when one exists.
98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
// <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 { LinkIcon } from "@hive/shared/icons.js";
|
|
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)}
|
|
>
|
|
{/* `@hive/shared/icons.js`'s `LinkIcon` — reported live by mara:
|
|
"links and settings icon styles different / all different
|
|
sizes". Now shared with `SettingsMenu`'s `GearIcon` and
|
|
agent's own `MetaNav` trigger, one rendering path instead of
|
|
three separate inline copies — see that file's comment. */}
|
|
<LinkIcon />
|
|
</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>
|
|
);
|
|
}
|