feat(adminsite): one masthead, a shared page frame, collapsible instance records
Server Deploy / deploy (push) Successful in 53s
Server Deploy / deploy (push) Successful in 53s
The console had components but no shell: a brand bar and a nav strip stacked into 100px carrying eight words, no sign-out, no account identity, and an Overview link hardcoded to text-accent so it read as the current page on every screen. Nine pages each hand-rolled their own header. AppBar replaces both bars and derives its active state from usePathname. Settings moves into AccountMenu — it is your password, not a destination — taking appearance with it, which finally sets the data-theme attribute the token blocks have supported in both directions since they were written. That leaves three customer destinations: Overview, People, Billing. PageFrame adds a support rail so a page has a floor, and InstanceRecord replaces InstanceCard with one component that opens and closes: an account with a single instance used to render a third of a row of summary with its substance a click away. It defaults open when the instance is the only one or needs attention. No plan card in the rail: tier, limits and expiry belong to a licence and a licence belongs to one instance, so an account holding a Free cloud instance and a Professional self-hosted one has no single plan. The rail carries only what is account-wide. Tokens and globals.css are untouched — they stay verbatim shared with site/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { useTheme, type ThemePref } from "@/lib/theme";
|
||||
|
||||
const APPEARANCE: { value: ThemePref; label: string }[] = [
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "system", label: "System" },
|
||||
];
|
||||
|
||||
/*
|
||||
* Everything here is about YOU rather than about the account: your settings,
|
||||
* your password, how you want the app to look, and leaving. None of it is a
|
||||
* destination worth a slot in the primary nav, which is why Settings moved off
|
||||
* the bar and into this menu.
|
||||
*/
|
||||
export function AccountMenu({ staff = false }: { staff?: boolean }) {
|
||||
const { session } = useSession();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pref, setPref] = useTheme();
|
||||
const wrap = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const signOut = useMutation({
|
||||
mutationFn: api.logout,
|
||||
// Clear the cache before leaving: a cached account response outliving
|
||||
// the session would show the next person who signs in on this browser
|
||||
// the previous account's name for a beat.
|
||||
onSettled: () => {
|
||||
qc.clear();
|
||||
router.replace("/login");
|
||||
},
|
||||
});
|
||||
|
||||
const email = session?.email ?? "";
|
||||
const initials =
|
||||
email
|
||||
.split("@")[0]
|
||||
.split(/[.\-_]/)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? "")
|
||||
.join("") || "?";
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrap}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={`flex items-center gap-2 rounded-sm border px-2 py-1 text-[0.8rem] ${
|
||||
open ? "border-accent text-ink" : "border-rule text-ink-2"
|
||||
} bg-panel hover:border-ink-3`}
|
||||
>
|
||||
<span className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full bg-accent font-mono text-[0.56rem] font-bold text-accent-ink">
|
||||
{initials}
|
||||
</span>
|
||||
<span className="hidden max-w-[16ch] truncate sm:inline">{email}</span>
|
||||
<span aria-hidden className="text-[0.6rem] text-ink-3">
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
// --shadow rather than a literal: globals.css defines it per
|
||||
// theme, and a hardcoded rgba would be a colour value living
|
||||
// in a component, which this app's tokens rule forbids.
|
||||
className="absolute right-0 top-[calc(100%+8px)] z-50 grid w-64 overflow-hidden rounded border border-rule bg-panel shadow-[var(--shadow)]"
|
||||
>
|
||||
<div className="grid gap-0.5 border-b border-rule-soft px-3 py-2.5">
|
||||
<strong className="truncate text-[0.86rem]">{email}</strong>
|
||||
<span className="font-mono text-[0.66rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
{staff ? "Vantage staff" : (session?.account_role ?? "member")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!staff && (
|
||||
<Link
|
||||
href="/settings"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
className="px-3 py-2 text-[0.86rem] text-ink hover:bg-accent-wash"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1.5 border-y border-rule-soft px-3 py-2.5">
|
||||
<span className="font-mono text-[0.66rem] uppercase tracking-[0.12em] text-ink-3">
|
||||
Appearance
|
||||
</span>
|
||||
<div className="flex overflow-hidden rounded-sm border border-rule">
|
||||
{APPEARANCE.map((a) => (
|
||||
<button
|
||||
key={a.value}
|
||||
type="button"
|
||||
onClick={() => setPref(a.value)}
|
||||
aria-pressed={pref === a.value}
|
||||
className={`flex-1 px-0 py-1 font-mono text-[0.62rem] uppercase tracking-[0.08em] ${
|
||||
pref === a.value
|
||||
? "bg-accent text-accent-ink"
|
||||
: "bg-panel text-ink-3 hover:text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => signOut.mutate()}
|
||||
disabled={signOut.isPending}
|
||||
className="px-3 py-2 text-left text-[0.86rem] text-expired hover:bg-accent-wash"
|
||||
>
|
||||
{signOut.isPending ? "Signing out…" : "Sign out"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user