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>
150 lines
6.2 KiB
TypeScript
150 lines
6.2 KiB
TypeScript
"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>
|
|
);
|
|
}
|