the hive dashboard uses the swarm UI's links menu, beside the profile avatar
The links menu moves into @hive/shared and takes its links as a prop. The swarm UI feeds it /api/links as before; the dashboard feeds it stats, API docs and the swarm, and mounts it next to the profile menu in place of its own hamburger on the left. A link opens in a new tab unless its entry says it is a page of the same site.
This commit is contained in:
parent
b2e3b05198
commit
464b52004b
12 changed files with 192 additions and 239 deletions
|
|
@ -11,7 +11,7 @@ what you actually do here.
|
||||||
## Where things are
|
## Where things are
|
||||||
|
|
||||||
Everything starts at the **dashboard**, served at `/`. A pages menu
|
Everything starts at the **dashboard**, served at `/`. A pages menu
|
||||||
(the `☰` button at the left of the tab strip) links to the other
|
(the link icon beside the profile avatar, at the right of the tab strip) links to the other
|
||||||
surfaces — Stats, the API docs and the swarm UI — and each of
|
surfaces — Stats, the API docs and the swarm UI — and each of
|
||||||
those pages links back to the dashboard.
|
those pages links back to the dashboard.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,9 @@ is a standalone page reached from the pages menu, not from the
|
||||||
dashboard tab strip.
|
dashboard tab strip.
|
||||||
|
|
||||||
**Chrome header** (fixed, overlays the active tab pane):
|
**Chrome header** (fixed, overlays the active tab pane):
|
||||||
- **Pages menu**: a `☰` button at the left of the chrome (`links-menu.js`)
|
- **Pages menu**: a link icon at the right end of the chrome, beside the
|
||||||
opening a popover with links to **stats**
|
profile avatar (`links-menu.js`, `@hive/shared`'s `LinksMenu`, the same
|
||||||
|
component the swarm UI uses for its service links), opening a popover with links to **stats**
|
||||||
(`/stats.html`), **api docs** (`/api/docs/`) and **swarm** (the swarm
|
(`/stats.html`), **api docs** (`/api/docs/`) and **swarm** (the swarm
|
||||||
UI at the swarm apex domain, opened in a new tab). The swarm entry
|
UI at the swarm apex domain, opened in a new tab). The swarm entry
|
||||||
comes from `state.swarm_public_url`, which hive-c0re reads from
|
comes from `state.swarm_public_url`, which hive-c0re reads from
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
@import "@hive/shared/jobq-rollup.css";
|
@import "@hive/shared/jobq-rollup.css";
|
||||||
@import "@hive/shared/jobq-graph.css";
|
@import "@hive/shared/jobq-graph.css";
|
||||||
@import "@hive/shared/user-menu.css";
|
@import "@hive/shared/user-menu.css";
|
||||||
|
@import "@hive/shared/links-menu.css";
|
||||||
|
|
||||||
/* ─── tabbed dashboard chrome ──────────────────────────────────────
|
/* ─── tabbed dashboard chrome ──────────────────────────────────────
|
||||||
Top-of-page sticky header with banner + tab strip. SSE stays
|
Top-of-page sticky header with banner + tab strip. SSE stays
|
||||||
|
|
@ -43,71 +44,20 @@ body.dashboard-shell {
|
||||||
padding: 0.3em 1em;
|
padding: 0.3em 1em;
|
||||||
margin: 0 0 1em;
|
margin: 0 0 1em;
|
||||||
}
|
}
|
||||||
/* Profile menu (user-menu.js): the avatar at the right end of the chrome.
|
/* The pages menu and the profile menu, together at the right end of the
|
||||||
Its own look is `@hive/shared/user-menu.css`'s. */
|
chrome. Their own looks are `@hive/shared/links-menu.css`'s and
|
||||||
.user-menu-slot {
|
`@hive/shared/user-menu.css`'s. */
|
||||||
|
.dashboard-chrome-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25em;
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
/* Pages menu (links-menu.js): one icon button at the left of the chrome
|
.links-menu-slot,
|
||||||
that opens a popover of the standalone pages and the swarm UI. Same
|
.user-menu-slot {
|
||||||
shape as the swarm UI's LinksMenu — quiet until hovered or open. The
|
|
||||||
explicit `[hidden]` rule is needed because `display: flex` on the
|
|
||||||
popover would otherwise beat the UA `[hidden]` style. */
|
|
||||||
.links-menu {
|
|
||||||
position: relative;
|
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
.links-menu-button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2em;
|
|
||||||
height: 2em;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 0.4em;
|
|
||||||
background: none;
|
|
||||||
color: var(--purple);
|
|
||||||
font-size: 1em;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.links-menu-button:hover,
|
|
||||||
.links-menu-button[aria-expanded="true"] {
|
|
||||||
border-color: var(--border);
|
|
||||||
background: var(--bg-elev);
|
|
||||||
}
|
|
||||||
.links-menu-popover {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 0.4em);
|
|
||||||
left: 0;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 10em;
|
|
||||||
padding: 0.4em;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 0.5em;
|
|
||||||
background: var(--bg-elev);
|
|
||||||
box-shadow: 0 0.25em 0.75em rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
.links-menu-popover[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.links-menu-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5em;
|
|
||||||
padding: 0.4em 0.6em;
|
|
||||||
border-radius: 0.35em;
|
|
||||||
color: var(--fg);
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.links-menu-item:hover {
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
.banner-thin {
|
.banner-thin {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -125,7 +75,7 @@ body.dashboard-shell {
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.2em;
|
gap: 0.2em;
|
||||||
/* On one row with the pages menu; the chrome owns the gutter +
|
/* On one row with the chrome's menus; the chrome owns the gutter +
|
||||||
the bottom divider, so no outer padding / border here. */
|
the bottom divider, so no outer padding / border here. */
|
||||||
}
|
}
|
||||||
/* Shares the flat-pill aesthetic of the shared `.hive-tab` (sub-tabs on
|
/* Shares the flat-pill aesthetic of the shared `.hive-tab` (sub-tabs on
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,6 @@
|
||||||
button / shared links keep you on the same view. JS owns
|
button / shared links keep you on the same view. JS owns
|
||||||
the show/hide. -->
|
the show/hide. -->
|
||||||
<header class="dashboard-chrome">
|
<header class="dashboard-chrome">
|
||||||
<!-- Pages menu: the standalone pages (stats, API docs) and the
|
|
||||||
swarm UI. Filled by links-menu.js from /api/state. The pages link
|
|
||||||
back to `/`, which is this dashboard. -->
|
|
||||||
<div class="links-menu" id="links-menu"></div>
|
|
||||||
<nav class="tabbar" id="tabbar" role="tablist">
|
<nav class="tabbar" id="tabbar" role="tablist">
|
||||||
<a
|
<a
|
||||||
class="tab"
|
class="tab"
|
||||||
|
|
@ -74,11 +70,18 @@
|
||||||
under 4G3NTS. See tab-pane-agents.) -->
|
under 4G3NTS. See tab-pane-agents.) -->
|
||||||
|
|
||||||
<!-- ST4TS is a standalone page reached from the pages
|
<!-- ST4TS is a standalone page reached from the pages
|
||||||
menu at the left of this strip, not tabs — the strip holds only
|
menu at the right of this strip, not tabs — the strip holds only
|
||||||
in-page tabs. -->
|
in-page tabs. -->
|
||||||
</nav>
|
</nav>
|
||||||
<!-- Profile menu: the signed-in user, filled by user-menu.js. -->
|
<!-- The pages menu and the profile menu, together at the right.
|
||||||
<div class="user-menu-slot" id="user-menu"></div>
|
Pages menu: the standalone pages (stats, API docs) and the swarm UI,
|
||||||
|
filled by links-menu.js from /api/state; the pages link back to `/`,
|
||||||
|
which is this dashboard. Profile menu: the signed-in user, filled by
|
||||||
|
user-menu.js. -->
|
||||||
|
<div class="dashboard-chrome-actions">
|
||||||
|
<div class="links-menu-slot" id="links-menu"></div>
|
||||||
|
<div class="user-menu-slot" id="user-menu"></div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
|
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
|
||||||
|
|
|
||||||
|
|
@ -1,98 +1,36 @@
|
||||||
// Pages menu in the dashboard chrome: one button that opens a popover
|
// Pages menu in the dashboard chrome: one icon button, beside the profile
|
||||||
// linking the standalone pages (stats, API docs) and the swarm UI.
|
// avatar at the right, that opens a popover linking the standalone pages
|
||||||
// Modelled on the swarm UI's `LinksMenu`. The swarm entry only exists
|
// (stats, API docs) and the swarm UI. It is `@hive/shared`'s `LinksMenu`, the
|
||||||
// when `/api/state` carries `swarm_public_url`, so a hive without a
|
// same component the swarm UI mounts for its service links, rendered with
|
||||||
// configured swarm domain shows no dead link.
|
// Preact like the profile menu. The swarm entry only exists when `/api/state`
|
||||||
|
// carries `swarm_public_url`, so a hive without a configured swarm domain
|
||||||
|
// shows no dead link.
|
||||||
|
|
||||||
|
import { h, render } from "preact";
|
||||||
|
import { LinksMenu } from "@hive/shared/links-menu.js";
|
||||||
import { $ } from "./common.js";
|
import { $ } from "./common.js";
|
||||||
import { el } from "@hive/shared/dom.js";
|
|
||||||
|
|
||||||
/** Menu entries for a state snapshot. `external` opens in a new tab. */
|
/** Menu entries for a state snapshot. The two pages open in place; the swarm
|
||||||
|
* UI is another site, so it opens in a new tab. */
|
||||||
export function menuLinks(state) {
|
export function menuLinks(state) {
|
||||||
const links = [
|
const links = [
|
||||||
{ icon: "📊", label: "stats", url: "/stats.html" },
|
{ icon: "📊", label: "stats", url: "/stats.html", external: false },
|
||||||
{ icon: "🧬", label: "api docs", url: "/api/docs/" },
|
{ icon: "🧬", label: "api docs", url: "/api/docs/", external: false },
|
||||||
];
|
];
|
||||||
if (state && state.swarm_public_url) {
|
if (state && state.swarm_public_url) {
|
||||||
links.push({
|
links.push({
|
||||||
icon: "🐝",
|
icon: "🐝",
|
||||||
label: "swarm",
|
label: "swarm",
|
||||||
url: state.swarm_public_url,
|
url: state.swarm_public_url,
|
||||||
external: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return links;
|
return links;
|
||||||
}
|
}
|
||||||
|
|
||||||
let wired = false;
|
|
||||||
|
|
||||||
function closeMenu(root) {
|
|
||||||
const pop = root.querySelector(".links-menu-popover");
|
|
||||||
const btn = root.querySelector(".links-menu-button");
|
|
||||||
if (pop) pop.hidden = true;
|
|
||||||
if (btn) btn.setAttribute("aria-expanded", "false");
|
|
||||||
}
|
|
||||||
|
|
||||||
function wire(root) {
|
|
||||||
if (wired) return;
|
|
||||||
wired = true;
|
|
||||||
root.querySelector(".links-menu-button").addEventListener("click", () => {
|
|
||||||
const pop = root.querySelector(".links-menu-popover");
|
|
||||||
const btn = root.querySelector(".links-menu-button");
|
|
||||||
pop.hidden = !pop.hidden;
|
|
||||||
btn.setAttribute("aria-expanded", String(!pop.hidden));
|
|
||||||
});
|
|
||||||
// Outside click / Escape close the popover.
|
|
||||||
document.addEventListener("pointerdown", (e) => {
|
|
||||||
if (!root.contains(e.target)) closeMenu(root);
|
|
||||||
});
|
|
||||||
document.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Escape") closeMenu(root);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** (Re)render the menu into `#links-menu`. Safe to call on every state
|
/** (Re)render the menu into `#links-menu`. Safe to call on every state
|
||||||
* refresh: the open/closed state lives on the popover element, which is
|
* refresh: Preact diffs the same component, so an open popover stays open. */
|
||||||
* kept; only its items are rebuilt. */
|
|
||||||
export function renderLinksMenu(state) {
|
export function renderLinksMenu(state) {
|
||||||
const root = $("links-menu");
|
const root = $("links-menu");
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
if (!root.firstChild) {
|
render(h(LinksMenu, { links: menuLinks(state), label: "pages" }), root);
|
||||||
root.append(
|
|
||||||
el(
|
|
||||||
"button",
|
|
||||||
{
|
|
||||||
type: "button",
|
|
||||||
class: "links-menu-button",
|
|
||||||
"aria-haspopup": "true",
|
|
||||||
"aria-expanded": "false",
|
|
||||||
"aria-label": "pages",
|
|
||||||
title: "pages",
|
|
||||||
},
|
|
||||||
"☰",
|
|
||||||
),
|
|
||||||
el("div", { class: "links-menu-popover", role: "menu", hidden: "" }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const pop = root.querySelector(".links-menu-popover");
|
|
||||||
pop.replaceChildren(
|
|
||||||
...menuLinks(state).map((link) => {
|
|
||||||
const attrs = {
|
|
||||||
class: "links-menu-item",
|
|
||||||
href: link.url,
|
|
||||||
role: "menuitem",
|
|
||||||
};
|
|
||||||
if (link.external) {
|
|
||||||
attrs.target = "_blank";
|
|
||||||
attrs.rel = "noreferrer";
|
|
||||||
}
|
|
||||||
return el(
|
|
||||||
"a",
|
|
||||||
attrs,
|
|
||||||
el("span", { "aria-hidden": "true" }, link.icon),
|
|
||||||
link.label,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
wire(root);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,10 @@
|
||||||
"./expand-details-setting.js": "./src/settings/ExpandDetailsSetting.tsx",
|
"./expand-details-setting.js": "./src/settings/ExpandDetailsSetting.tsx",
|
||||||
"./settings-menu.css": "./src/settings/SettingsMenu.css",
|
"./settings-menu.css": "./src/settings/SettingsMenu.css",
|
||||||
"./user-menu.js": "./src/user-menu/UserMenu.tsx",
|
"./user-menu.js": "./src/user-menu/UserMenu.tsx",
|
||||||
"./user-menu.css": "./src/user-menu/UserMenu.css"
|
"./user-menu.css": "./src/user-menu/UserMenu.css",
|
||||||
|
"./links-menu.js": "./src/links-menu/LinksMenu.tsx",
|
||||||
|
"./links-menu.css": "./src/links-menu/LinksMenu.css",
|
||||||
|
"./links.js": "./src/links-menu/links.ts"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src/"
|
"src/"
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
creates a new `.jg-node` DOM node when its `key` (the node id) is
|
creates a new `.jg-node` DOM node when its `key` (the node id) is
|
||||||
genuinely new, so this only plays once per node, the first time it
|
genuinely new, so this only plays once per node, the first time it
|
||||||
appears in a fetched snapshot. Same shape as Shell.css's
|
appears in a fetched snapshot. Same shape as Shell.css's
|
||||||
`shell-page-enter` / LinksMenu.css's `links-menu-popover-enter` —
|
`shell-page-enter` / the links menu's `links-menu-popover-enter` —
|
||||||
see either for the three-rule motion-guard this mirrors. */
|
see either for the three-rule motion-guard this mirrors. */
|
||||||
.jg-node {
|
.jg-node {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
||||||
87
frontend/packages/shared/src/links-menu/LinksMenu.css
Normal file
87
frontend/packages/shared/src/links-menu/LinksMenu.css
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
/* <LinksMenu> (@hive/shared) — a single header icon-button + popover. Kept visually
|
||||||
|
quiet (no border/fill until interacted with) so it reads as chrome,
|
||||||
|
not another nav item. Button is sized to the same touch-target floor
|
||||||
|
as the shared `ui/` kit (2.75em ≈ 44px, WCAG 2.5.5) even though it
|
||||||
|
isn't built from that kit — a bespoke icon trigger, not a form
|
||||||
|
control, but the floor applies regardless of which component drew
|
||||||
|
it. No `margin-left: auto` here: the mounting page's header owns pushing the
|
||||||
|
action group to the right edge (the swarm UI's `.shell-header-actions`,
|
||||||
|
the dashboard's `.dashboard-chrome-actions`). */
|
||||||
|
.links-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.links-menu-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.75em;
|
||||||
|
height: 2.75em;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.4em;
|
||||||
|
background: none;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.links-menu-button:hover,
|
||||||
|
.links-menu-button[aria-expanded="true"] {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
.links-menu-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.4em);
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 10em;
|
||||||
|
padding: 0.4em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
box-shadow: 0 0.25em 0.75em rgba(0, 0, 0, 0.3);
|
||||||
|
animation: links-menu-popover-enter 140ms ease;
|
||||||
|
}
|
||||||
|
/* Mount-triggered, not a state transition — the popover only exists in
|
||||||
|
the DOM while open (`{open ? <div> : null}`), so a keyframe animation
|
||||||
|
on entry is the right tool, same shape as `.shell-body`'s own
|
||||||
|
`shell-page-enter` (Shell.css) — that's the precedent every popover
|
||||||
|
in this issue's "menus should animate" ask follows. */
|
||||||
|
@keyframes links-menu-popover-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-0.25em) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.links-menu-popover {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-motion="reduce"] .links-menu-popover {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
:root[data-motion="allow"] .links-menu-popover {
|
||||||
|
animation: links-menu-popover-enter 140ms ease;
|
||||||
|
}
|
||||||
|
.links-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
min-height: 2.75em;
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
border-radius: 0.35em;
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.links-menu-item:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
81
frontend/packages/shared/src/links-menu/LinksMenu.tsx
Normal file
81
frontend/packages/shared/src/links-menu/LinksMenu.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// <LinksMenu> — a single header icon-button opening a popover of links, next
|
||||||
|
// to the profile avatar. The swarm UI hands it the swarm's service list and
|
||||||
|
// the hive dashboard its own pages; both look and behave the same, which is
|
||||||
|
// why it lives here (it was the swarm UI's, and the dashboard had its own
|
||||||
|
// copy with a hamburger on the left).
|
||||||
|
//
|
||||||
|
// A menu with no links renders nothing: no button beats a dead one.
|
||||||
|
//
|
||||||
|
// Styles live in `@hive/shared/links-menu.css`, which the mounting page
|
||||||
|
// imports or `@import`s itself: the dashboard's JS bundle loads `.css`
|
||||||
|
// imports as text, so a component that imported it would not style there
|
||||||
|
// (the same split as `UserMenu`).
|
||||||
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import { LinkIcon } from "../icons.js";
|
||||||
|
import { linkAttrs, type MenuLink } from "./links.js";
|
||||||
|
|
||||||
|
export type { MenuLink };
|
||||||
|
|
||||||
|
export function LinksMenu({
|
||||||
|
links,
|
||||||
|
label = "links",
|
||||||
|
}: {
|
||||||
|
links: MenuLink[];
|
||||||
|
/** The button's accessible name. */
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
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.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={label}
|
||||||
|
title={label}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<LinkIcon />
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div class="links-menu-popover" role="menu">
|
||||||
|
{links.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.url}
|
||||||
|
class="links-menu-item"
|
||||||
|
href={link.url}
|
||||||
|
{...linkAttrs(link)}
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{link.icon ? <span aria-hidden="true">{link.icon}</span> : null}
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/packages/shared/src/links-menu/links.ts
Normal file
25
frontend/packages/shared/src/links-menu/links.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
// What the links menu shows for one link, without a DOM: where it opens and
|
||||||
|
// what it is called. Shared by the swarm UI (its `/api/links`) and the hive
|
||||||
|
// dashboard (its stats and API docs pages, and the swarm UI).
|
||||||
|
|
||||||
|
/** One entry of the links menu. */
|
||||||
|
export interface MenuLink {
|
||||||
|
label: string;
|
||||||
|
/** A glyph before the label; none leaves the label alone. */
|
||||||
|
icon?: string;
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* Opens in a new tab. Defaults to true: a link the menu was handed from
|
||||||
|
* elsewhere (the swarm's service list) leaves the page. A page of the
|
||||||
|
* same site says `false` and opens in place.
|
||||||
|
*/
|
||||||
|
external?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The anchor attributes that make a link open where its entry says. */
|
||||||
|
export function linkAttrs(link: MenuLink): {
|
||||||
|
target?: string;
|
||||||
|
rel?: string;
|
||||||
|
} {
|
||||||
|
return link.external === false ? {} : { target: "_blank", rel: "noreferrer" };
|
||||||
|
}
|
||||||
|
|
@ -1,98 +1,27 @@
|
||||||
// <LinksMenu> — the "accessible from every screen" affordance for
|
// <LinksMenu> — the swarm UI's links menu: `@hive/shared`'s, fed the swarm's
|
||||||
// swarm-wide services (authelia, matrix, forge, this UI's own API
|
// service list from `GET /api/links` (the services behind the swarm's vhosts,
|
||||||
// docs, …): one fixed header button that never grows the header
|
// each with its glyph). A failed fetch leaves the list empty, and an empty
|
||||||
// itself as the list grows, opening a popover with everything
|
// menu draws nothing: no button beats a broken one.
|
||||||
// `GET /api/links` returns. Lives in `Shell` (not `ui/`) because it
|
import { useEffect, useState } from "preact/hooks";
|
||||||
// owns its own fetch — every other `ui/` primitive is presentational
|
import {
|
||||||
// only, this one genuinely isn't reusable outside this one job.
|
LinksMenu as SharedLinksMenu,
|
||||||
//
|
type MenuLink,
|
||||||
// The list is entirely server-driven (see swarm-controller's
|
} from "@hive/shared/links-menu.js";
|
||||||
// `services.hyperhive.swarm.controller.links` option) — 0 entries
|
import "@hive/shared/links-menu.css";
|
||||||
// hides the button rather than showing an empty popover, same "don't
|
|
||||||
// render a dead affordance" rule the dashboard's pages menu follows for
|
|
||||||
// its swarm entry.
|
|
||||||
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() {
|
export function LinksMenu() {
|
||||||
const [links, setLinks] = useState<ServiceLink[] | null>(null);
|
const [links, setLinks] = useState<MenuLink[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/links")
|
fetch("/api/links")
|
||||||
.then((r) =>
|
.then((r) =>
|
||||||
r.ok
|
r.ok
|
||||||
? (r.json() as Promise<ServiceLink[]>)
|
? (r.json() as Promise<MenuLink[]>)
|
||||||
: Promise.reject(new Error(`http ${r.status}`)),
|
: Promise.reject(new Error(`http ${r.status}`)),
|
||||||
)
|
)
|
||||||
.then(setLinks)
|
.then(setLinks)
|
||||||
.catch(() => setLinks([])); // best-effort: no button beats a broken one
|
.catch(() => setLinks([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Close on an outside click or Escape — only listens while open, so
|
return <SharedLinksMenu links={links} label="swarm services" />;
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
frontend/packages/swarm-ui/test/links-menu.test.mjs
Normal file
23
frontend/packages/swarm-ui/test/links-menu.test.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// How a link in the links menu opens. Run by `npm test`, which Node runs with
|
||||||
|
// its built-in test runner and type stripping.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { linkAttrs } from "../../shared/src/links-menu/links.ts";
|
||||||
|
|
||||||
|
test("a link the menu was handed opens in a new tab, without a referrer", () => {
|
||||||
|
assert.deepEqual(linkAttrs({ label: "forge", url: "https://forge.x" }), {
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noreferrer",
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
linkAttrs({ label: "forge", url: "https://forge.x", external: true }),
|
||||||
|
{ target: "_blank", rel: "noreferrer" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a page of the same site opens in place", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
linkAttrs({ label: "stats", url: "/stats.html", external: false }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue