Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/shell/LinksMenu.tsx
atlas 39b95c2ede treefmt: apply prettier
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.
2026-09-02 15:25:07 +02:00

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>
);
}