feat(adminsite): one masthead, a shared page frame, collapsible instance records
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:
mrhid6
2026-07-26 17:45:53 +01:00
co-authored by Claude Opus 5
parent 6bf288f83e
commit 34a0373eca
27 changed files with 1591 additions and 531 deletions
+94 -45
View File
@@ -3,60 +3,109 @@
import { useQuery } from "@tanstack/react-query";
import { API_BASE, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { formatDate } from "@/lib/format";
export default function BillingPage() {
const { data, error, isLoading } = useQuery({
queryKey: ["subscriptions"],
queryFn: api.subscriptions,
});
const subs = useQuery({ queryKey: ["subscriptions"], queryFn: api.subscriptions });
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
if (error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
if (isLoading) return <p className="text-ink-3">Loading</p>;
if (subs.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
if (subs.isLoading) return <p className="text-ink-3">Loading</p>;
const rows = subs.data ?? [];
// Each subscription names the instance it pays for, because tier and term
// are per-licence rather than per-account. Resolving the name here is the
// difference between "professional · annual" and knowing which install that is.
const nameFor = (instanceId?: string) =>
account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
return (
<div className="grid gap-6">
<h1 className="text-3xl">Billing</h1>
<PageHeader
title="Billing"
subtitle="One subscription per instance — each carries its own tier and term."
record={
account.data
? [{ key: "Billing", value: account.data.account.billing_email }]
: undefined
}
/>
{!data || data.length === 0 ? (
<p className="text-ink-2">
You have no subscriptions. Cloud instances and self-hosted licences are both
bought from the pricing page.
</p>
) : (
<div className="overflow-x-auto rounded border border-rule bg-panel">
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
<th className="px-4 py-2.5">Plan</th>
<th className="px-4 py-2.5">Term</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5">Renews</th>
</tr>
</thead>
<tbody>
{data.map((s) => (
<tr
key={s.subscription_id}
className="border-b border-rule-soft last:border-0"
>
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
<td className="px-4 py-3">{s.term}</td>
<td className="px-4 py-3">{s.status}</td>
<td className="px-4 py-3 font-mono tabular-nums">
{formatDate(s.current_period_end)}
</td>
<PageFrame
aside={
<>
<RailCard title="Account">
<RailFacts
rows={[
{
label: "Billing contact",
value: account.data?.account.billing_email ?? "—",
},
{ label: "Status", value: account.data?.account.status ?? "—" },
{ label: "Subscriptions", value: rows.length },
]}
/>
</RailCard>
<RailCard title="Need a change?">
<p className="text-[0.82rem] text-ink-2">
To change a card, download an invoice or cancel, email support and
we will send you a billing link. Self-service billing arrives with
card payments.
</p>
<a
href="mailto:support@hostxtra.co.uk"
className="text-[0.82rem] font-semibold text-accent underline"
>
support@hostxtra.co.uk
</a>
</RailCard>
</>
}
>
{rows.length === 0 ? (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
You have no subscriptions. Cloud instances and self-hosted licences are
both bought from the pricing page.
</p>
) : (
<div className="overflow-x-auto rounded border border-rule bg-panel">
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
<th className="px-4 py-2.5 font-normal">Instance</th>
<th className="px-4 py-2.5 font-normal">Plan</th>
<th className="px-4 py-2.5 font-normal">Term</th>
<th className="px-4 py-2.5 font-normal">Status</th>
<th className="px-4 py-2.5 font-normal">Renews</th>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="max-w-xl text-[0.82rem] text-ink-3">
To change a card, download an invoice or cancel, email support and we will send you
a billing link. Self-service billing arrives with card payments.
</p>
</thead>
<tbody>
{rows.map((s) => (
<tr
key={s.subscription_id}
className="border-b border-rule-soft last:border-0"
>
<td className="px-4 py-3">
{nameFor(s.instance_id) ?? (
<span className="text-ink-3">Not linked yet</span>
)}
</td>
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
<td className="px-4 py-3">{s.term}</td>
<td className="px-4 py-3">{s.status}</td>
<td className="px-4 py-3 font-mono tabular-nums">
{formatDate(s.current_period_end)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</PageFrame>
</div>
);
}
+111 -57
View File
@@ -9,6 +9,8 @@ import { LicenceDelivery } from "@/components/LicenceDelivery";
import { MembersPanel } from "@/components/MembersPanel";
import { RelinkPanel } from "@/components/RelinkPanel";
import { StatePill } from "@/components/StatePill";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
export default function InstancePage() {
@@ -47,68 +49,120 @@ export default function InstancePage() {
const lic = licence.data;
const state = licenceState(lic?.expires_at, Boolean(lic));
const cloud = instance.deployment === "cloud";
return (
<div className="grid gap-8">
<header className="grid gap-3">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl">{instance.name}</h1>
<StatePill state={state} />
</div>
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">
{instance.instance_id}
</p>
</header>
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title={instance.name || "Unnamed instance"}
subtitle={`${cloud ? "Cloud" : "Self-hosted"}${
instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""
} · created ${formatDate(instance.created_at)}`}
record={[
{ key: "Instance", value: instance.instance_id, copy: true },
...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : []),
]}
status={<StatePill state={state} />}
/>
{lic ? (
<>
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Fact label="Tier" value={lic.tier.replace("_", " ")} />
<Fact label="Expires" value={formatDate(lic.expires_at)} />
<Fact label="Servers" value={limitLabel(lic.limits.max_servers)} />
<Fact label="Features" value={lic.features.join(", ") || "none"} />
</dl>
<PageFrame
aside={
<>
<RailCard title="Licence">
{lic ? (
<RailFacts
rows={[
{ label: "Tier", value: lic.tier.replace("_", " ") },
{ label: "Issued", value: formatDate(lic.issued_at) },
{ label: "Expires", value: formatDate(lic.expires_at) },
{ label: "Reason", value: lic.reason },
]}
/>
) : (
<p className="text-[0.82rem] text-ink-2">
No licence issued yet.
</p>
)}
</RailCard>
{instance.deployment === "self_hosted" && (
<>
<LicenceDelivery
instanceId={instance.instance_id}
blob={lic.blob ?? ""}
downloadUrl={api.licenseBlobUrl(instance.instance_id)}
/>
<RelinkPanel
instanceId={instance.instance_id}
used={instance.relink_count}
max={account.data?.max_relinks ?? 3}
error={relinkError}
onRelink={(newId) => relink.mutate(newId)}
/>
</>
)}
</>
) : (
<p className="text-ink-2">No licence has been issued for this instance yet.</p>
)}
{lic && (
<RailCard title="Included">
<RailFacts
rows={[
{
label: "Servers",
value: limitLabel(lic.limits.max_servers),
},
{
label: "Secret groups",
value: limitLabel(lic.limits.max_secret_groups),
},
{
label: "Channels",
value: limitLabel(lic.limits.max_channels),
},
{
label: "Features",
value: lic.features.join(", ") || "none",
},
]}
/>
</RailCard>
)}
{instance.deployment === "cloud" ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
Users for this install are managed inside it, in Settings Instance. We do not
have access to your own deployment.
</p>
)}
</div>
);
}
function Fact({ label, value }: { label: string; value: string }) {
return (
<div className="grid gap-1">
<dt className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
{label}
</dt>
<dd className="font-mono tabular-nums">{value}</dd>
{!cloud && (
<RailCard title="Moves">
<RailFacts
rows={[
{
label: "Relinks used",
value: `${instance.relink_count} of ${
account.data?.max_relinks ?? 3
}`,
},
]}
/>
<p className="text-[0.82rem] text-ink-2">
Moving a licence to a different install counts as one.
</p>
</RailCard>
)}
</>
}
>
{cloud ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
Users for this install are managed inside it, in Settings Instance. We do
not have access to your own deployment.
</p>
)}
{lic && !cloud && (
<>
<LicenceDelivery
instanceId={instance.instance_id}
blob={lic.blob ?? ""}
downloadUrl={api.licenseBlobUrl(instance.instance_id)}
/>
<RelinkPanel
instanceId={instance.instance_id}
used={instance.relink_count}
max={account.data?.max_relinks ?? 3}
error={relinkError}
onRelink={(newId) => relink.mutate(newId)}
/>
</>
)}
{!lic && (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
No licence has been issued for this instance yet.
</p>
)}
</PageFrame>
</div>
);
}
@@ -3,6 +3,7 @@
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { LinkForm } from "./LinkForm";
import { PageHeader } from "@/components/PageHeader";
export default function LinkPage() {
const router = useRouter();
@@ -10,13 +11,11 @@ export default function LinkPage() {
return (
<div className="grid max-w-2xl gap-6">
<header className="grid gap-2">
<h1 className="text-3xl">Link an install</h1>
<p className="text-ink-2">
Every licence is tied to one install, so we need its ID before we can issue
yours. Paste it below and your licence is ready on the next screen.
</p>
</header>
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Link an install"
subtitle="Every licence is tied to one install, so we need its ID before we can issue yours. Paste it below and your licence is ready on the next screen."
/>
<LinkForm
onLinked={(instanceId) => {
qc.invalidateQueries({ queryKey: ["account"] });
@@ -1,19 +1,17 @@
import type { Metadata } from "next";
import { CreateForm } from "./CreateForm";
import { PageHeader } from "@/components/PageHeader";
export const metadata: Metadata = { title: "New instance" };
export default function NewInstancePage() {
return (
<div className="grid gap-6">
<header className="grid gap-2">
<h1 className="text-3xl">Create a free instance</h1>
<p className="max-w-prose text-ink-2">
An instance owns its servers, keys, workflows, monitors and secrets. Nothing
inside it is visible to any other instance. Free covers three servers, and the
licence runs for a month at a time we email you before it needs renewing.
</p>
</header>
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Create a free instance"
subtitle="An instance owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other instance. Free covers three servers, and the licence runs for a month at a time — we email you before it needs renewing."
/>
<CreateForm />
</div>
);
+29 -21
View File
@@ -1,31 +1,39 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { RequireKind } from "@/lib/session";
import { AppBar, type NavLink } from "@/components/AppBar";
/*
* Three destinations, not five. Settings moved into the account menu — it is
* your password, not a place — and Instances went with it, because Overview
* already lists them and a second door to the same room is just a second thing
* to keep in sync. Linking an install is an action, so it is a button on
* Overview rather than a permanent nav entry.
*/
const LINKS: NavLink[] = [
{ href: "/", label: "Overview" },
{ href: "/users", label: "People" },
{ href: "/billing", label: "Billing" },
];
function AccountName() {
// Shares the ["account"] key with Overview, so this costs no extra request.
const { data } = useQuery({ queryKey: ["account"], queryFn: api.account });
if (!data) return null;
return (
<span className="block truncate text-[0.92rem] font-bold tracking-[-0.01em]">
{data.account.name}
</span>
);
}
export default function CustomerLayout({ children }: { children: React.ReactNode }) {
return (
<RequireKind kind="customer">
<nav className="border-b border-rule-soft bg-panel-2">
<div className="mx-auto flex max-w-rail flex-wrap gap-5 px-5 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em]">
<Link href="/" className="text-accent">
Overview
</Link>
<Link href="/instances/link" className="text-ink-3">
Link an install
</Link>
<Link href="/billing" className="text-ink-3">
Billing
</Link>
<Link href="/users" className="text-ink-3">
People
</Link>
<Link href="/settings" className="text-ink-3">
Settings
</Link>
</div>
</nav>
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
<AppBar links={LINKS} context={<AccountName />} />
<main className="mx-auto max-w-rail px-5 py-7">{children}</main>
</RequireKind>
);
}
+178 -54
View File
@@ -4,10 +4,16 @@ import { useQueries, useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { API_BASE, NotConnected, api, type License } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { InstanceCard } from "@/components/InstanceCard";
import { InstanceRecord } from "@/components/InstanceRecord";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { LinkButton } from "@/components/Button";
import { StatePill } from "@/components/StatePill";
import { daysRemaining, formatDate, licenceState } from "@/lib/format";
export default function OverviewPage() {
const { data, error, isLoading } = useQuery({ queryKey: ["account"], queryFn: api.account });
const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const licences = useQueries({
queries: (data?.instances ?? [])
@@ -26,35 +32,62 @@ export default function OverviewPage() {
if (q.data) byInstance.set(q.data.instance_id, q.data);
});
const unlinked = data.instances.filter((i) => i.status === "awaiting_link");
const live = data.instances.filter((i) => i.status !== "deleted");
// Work the customer has to do, gathered across every instance. This is the
// only account-level view of it — each record only knows about itself.
const attention = live.flatMap((i) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
if (state === "none")
return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
if (state === "expired")
return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
if (state === "warn")
return [
{
id: i.instance_id,
text: `${i.name} expires`,
note: `${daysRemaining(lic!.expires_at)}d`,
},
];
return [];
});
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
const hasFree = live.some((i) => i.tier === "free" && i.status !== "cancelled");
const subtitle =
live.length === 0
? "Nothing here yet."
: `${live.length} ${live.length === 1 ? "instance" : "instances"}${
attention.length ? ` · ${attention.length} needing attention` : " · all licensed"
}`;
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">{data.account.name}</h1>
<p className="text-ink-2">{data.account.billing_email}</p>
</header>
<div className="grid gap-6">
<PageHeader
title="Overview"
subtitle={subtitle}
actions={
!hasFree && live.length > 0 ? (
<LinkButton variant="line" href="/instances/new">
Create a free instance
</LinkButton>
) : undefined
}
record={[
{ key: "Account", value: data.account.account_id, copy: true },
{ key: "Billing", value: data.account.billing_email },
]}
status={
attention.length === 0 && live.length > 0 ? (
<StatePill state="valid" />
) : undefined
}
/>
{unlinked.length > 0 && (
<div className="rounded border border-accent bg-accent-wash p-4">
<h2 className="text-xl">Finish setting up your licence</h2>
<p className="mt-1 text-[0.82rem] text-ink-2">
{unlinked.length === 1
? "One purchase is"
: `${unlinked.length} purchases are`}{" "}
not attached to an install yet, so no licence has been issued for{" "}
{unlinked.length === 1 ? "it" : "them"}.
</p>
<Link
href="/instances/link"
className="mt-2 inline-block text-[0.82rem] font-semibold text-accent underline"
>
Link an install
</Link>
</div>
)}
{data.instances.length === 0 ? (
{live.length === 0 ? (
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">No instances yet</h2>
<p className="text-ink-2">
@@ -62,36 +95,127 @@ export default function OverviewPage() {
automatically. Or buy a self-hosted licence, install Vantage on your own
server, and link it here to get your licence file.
</p>
<Link
href="/instances/new"
className="justify-self-start rounded bg-accent px-4 py-2 text-[0.9rem] font-semibold text-accent-ink"
>
Create a free instance
</Link>
<div className="flex flex-wrap gap-2.5">
<LinkButton href="/instances/new">Create a free instance</LinkButton>
<LinkButton variant="line" href="/instances/link">
Link an install
</LinkButton>
</div>
</div>
) : (
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{data.instances.map((i) => (
<InstanceCard
key={i.instance_id}
instance={i}
license={byInstance.get(i.instance_id)}
/>
))}
</section>
)}
<PageFrame
aside={
<>
{attention.length > 0 && (
<RailCard title="Needs you" count={attention.length}>
<ul className="grid gap-2">
{attention.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
>
<span>{a.text}</span>
{a.note && (
<span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">
{a.note}
</span>
)}
</li>
))}
</ul>
</RailCard>
)}
{data.instances.length > 0 &&
!data.instances.some(
(i) => i.tier === "free" && i.status !== "cancelled" && i.status !== "deleted",
) && (
<Link
href="/instances/new"
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
Create a free instance
</Link>
)}
<RailCard title="Your team" count={people.data?.length}>
<ul className="grid gap-2">
{(people.data ?? []).slice(0, 5).map((p) => (
<li
key={p.user_id}
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
>
<span className="truncate">{p.email}</span>
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.08em] text-ink-3">
{p.account_role}
</span>
</li>
))}
</ul>
{pending > 0 && (
<p className="border-t border-rule-soft pt-2 text-[0.78rem] text-warn">
{pending} {pending === 1 ? "invitation" : "invitations"} not
accepted yet
</p>
)}
<Link
href="/users"
className="text-[0.82rem] font-semibold text-accent underline"
>
Manage people
</Link>
</RailCard>
{/*
* Account-level facts only. Tier, limits and renewal date
* belong to a licence, and a licence belongs to one
* instance — an account holding a Free cloud instance and
* a Professional self-hosted one has no single plan.
*/}
<RailCard title="Account">
<RailFacts
rows={[
{ label: "Billing contact", value: data.account.billing_email },
{ label: "Status", value: data.account.status },
{
label: "Customer since",
value: formatDate(data.account.created_at),
},
]}
/>
<Link
href="/billing"
className="text-[0.82rem] font-semibold text-accent underline"
>
Billing history
</Link>
</RailCard>
<RailCard title="Running Vantage yourself?">
<p className="text-[0.82rem] text-ink-2">
Link your own install to get its licence file. It keeps its own
users.
</p>
<Link
href="/instances/link"
className="text-[0.82rem] font-semibold text-accent underline"
>
Link an install
</Link>
</RailCard>
</>
}
>
{live.map((i, n) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
return (
<InstanceRecord
key={i.instance_id}
instance={i}
license={lic}
// Open when it is the only one, or when it is the
// first thing that needs a decision. A saved toggle
// beats this from then on.
defaultOpen={
live.length === 1 ||
(state !== "valid" &&
attention[0]?.id === i.instance_id) ||
(attention.length === 0 && n === 0)
}
/>
);
})}
</PageFrame>
)}
</div>
);
}
+7 -8
View File
@@ -5,6 +5,7 @@ import { useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { PageHeader } from "@/components/PageHeader";
export default function SettingsPage() {
const [current, setCurrent] = useState("");
@@ -28,14 +29,12 @@ export default function SettingsPage() {
});
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">Settings</h1>
<p className="text-ink-2">
Your password signs you in here and into every Vantage instance you belong to.
Changing it changes all of them.
</p>
</header>
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Settings"
subtitle="Your password signs you in here and into every Vantage instance you belong to. Changing it changes all of them."
/>
<form
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
+148 -111
View File
@@ -7,9 +7,17 @@ import { useSession } from "@/lib/session";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { PageFrame, RailCard } from "@/components/PageFrame";
import { formatDate } from "@/lib/format";
const ROLES: AccountRole[] = ["owner", "admin", "member"];
const WHAT_ROLES_DO: [AccountRole, string][] = [
["owner", "Everything, including billing."],
["admin", "Invite people, create instances, grant access. No billing."],
["member", "Sign in to the instances they are given."],
];
export function InvitePanel() {
const qc = useQueryClient();
const { session } = useSession();
@@ -49,125 +57,154 @@ export function InvitePanel() {
const assignable = myRole === "owner" ? ROLES : ROLES.filter((r) => r !== "owner");
return (
<div className="grid gap-6">
<PageFrame
aside={
<>
{canManage && (
<RailCard title="Invite someone">
<form
className="grid gap-3"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (email.trim()) invite.mutate();
}}
>
<Field
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
hint="They choose their own password from the emailed link. Nothing happens until they open it."
/>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Account role
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as AccountRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<Button type="submit" disabled={invite.isPending || !email.trim()}>
{invite.isPending ? "Sending…" : "Send invitation"}
</Button>
</form>
</RailCard>
)}
<RailCard title="What the roles do">
<dl className="grid gap-2">
{WHAT_ROLES_DO.map(([r, what]) => (
<div key={r} className="grid gap-0.5">
<dt className="font-mono text-[0.68rem] uppercase tracking-[0.08em] text-ink">
{r}
</dt>
<dd className="m-0 text-[0.8rem] text-ink-2">{what}</dd>
</div>
))}
</dl>
<p className="border-t border-rule-soft pt-2 text-[0.8rem] text-ink-2">
An account role is not access to an instance. Give someone that on the
instance itself.
</p>
</RailCard>
</>
}
>
{error && (
<p className="rounded border border-expired bg-panel p-3 text-[0.9rem] text-expired">
{error}
</p>
)}
<table className="w-full border-collapse text-left text-[0.9rem]">
<thead>
<tr className="border-b border-rule font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
<th className="py-2">Email</th>
<th className="py-2">Account role</th>
<th className="py-2">Status</th>
<th className="py-2" />
</tr>
</thead>
<tbody>
{(users.data ?? []).map((u) => {
const isSelf = u.email === session?.email;
return (
<tr key={u.user_id} className="border-b border-rule-soft">
<td className="py-2.5">
{u.email}
{isSelf && <span className="ml-2 text-ink-3">(you)</span>}
</td>
<td className="py-2.5">
{canManage && !isSelf ? (
<select
value={u.account_role}
onChange={(e) =>
setRoleFor.mutate({
id: u.user_id,
role: e.target.value as AccountRole,
})
}
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
) : (
<span className="font-mono text-[0.82rem]">
{u.account_role}
</span>
)}
</td>
<td className="py-2.5 text-ink-2">
{u.verified_at ? "Active" : "Invitation pending"}
</td>
<td className="py-2.5 text-right">
{canManage && !isSelf && (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (
confirm(
`Remove ${u.email}? They lose access to every instance on this account.`,
<div className="overflow-x-auto rounded border border-rule bg-panel">
<table className="w-full border-collapse text-left text-[0.9rem]">
<thead>
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
<th className="px-4 py-2.5 font-normal">Email</th>
<th className="px-4 py-2.5 font-normal">Account role</th>
<th className="px-4 py-2.5 font-normal">Status</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{(users.data ?? []).map((u) => {
const isSelf = u.email === session?.email;
return (
<tr key={u.user_id} className="border-b border-rule-soft last:border-0">
<td className="px-4 py-3">
{u.email}
{isSelf && <span className="ml-2 text-ink-3">(you)</span>}
</td>
<td className="px-4 py-3">
{canManage && !isSelf ? (
<select
value={u.account_role}
onChange={(e) =>
setRoleFor.mutate({
id: u.user_id,
role: e.target.value as AccountRole,
})
}
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
) : (
<span className="font-mono text-[0.82rem]">
{u.account_role}
</span>
)}
</td>
<td className="px-4 py-3 text-ink-2">
{u.verified_at
? `Active since ${formatDate(u.verified_at)}`
: "Invitation pending"}
</td>
<td className="px-4 py-3 text-right">
{canManage && !isSelf && (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (
confirm(
`Remove ${u.email}? They lose access to every instance on this account.`,
)
)
)
remove.mutate(u.user_id);
}}
>
Remove
</button>
)}
remove.mutate(u.user_id);
}}
>
Remove
</button>
)}
</td>
</tr>
);
})}
{users.data?.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-ink-3">
Nobody yet.
</td>
</tr>
);
})}
</tbody>
</table>
{canManage && (
<form
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (email.trim()) invite.mutate();
}}
>
<h2 className="text-xl">Invite someone</h2>
<Field
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
hint="They choose their own password from the emailed link. Nothing happens until they open it."
/>
<label className="grid max-w-md gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Account role
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as AccountRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<p className="text-[0.82rem] text-ink-2">
An account role is not access to an instance. Give them that on the
instance itself.
</p>
<Button type="submit" disabled={invite.isPending || !email.trim()}>
{invite.isPending ? "Sending…" : "Send invitation"}
</Button>
</form>
)}
</div>
)}
</tbody>
</table>
</div>
</PageFrame>
);
}
+8 -8
View File
@@ -1,15 +1,15 @@
"use client";
import { InvitePanel } from "./InvitePanel";
import { PageHeader } from "@/components/PageHeader";
export default function UsersPage() {
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">People</h1>
<p className="text-ink-2">
Everyone on this account. Owners and admins can invite people and grant them
access to instances; billing stays with owners.
</p>
</header>
<div className="grid gap-6">
<PageHeader
title="People"
subtitle="Everyone on this account. Owners and admins can invite people and grant them access to instances; billing stays with owners."
/>
<InvitePanel />
</div>
);
@@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
export default function AccountDetailPage() {
const id = String(useParams().id);
@@ -17,12 +18,18 @@ export default function AccountDetailPage() {
return (
<div className="grid gap-8">
<header className="grid gap-1">
<h1 className="text-3xl">{data.account.name}</h1>
<p className="font-mono text-[0.82rem] text-ink-3">
{data.account.billing_email} · {data.account.account_id}
</p>
</header>
<PageHeader
back={{ href: "/staff/accounts", label: "Accounts" }}
title={data.account.name}
subtitle={data.account.billing_email}
record={[
{ key: "Account", value: data.account.account_id, copy: true },
{ key: "Status", value: data.account.status },
...(data.account.paddle_customer_id
? [{ key: "Paddle", value: data.account.paddle_customer_id, copy: true }]
: []),
]}
/>
<Panel title="Instances">
<ul className="grid gap-2">
@@ -1,9 +1,13 @@
import { AccountSearch } from "./AccountSearch";
import { PageHeader } from "@/components/PageHeader";
export default function AccountsPage() {
return (
<div className="grid gap-6">
<h1 className="text-3xl">Accounts</h1>
<PageHeader
title="Accounts"
subtitle="Search by name, email, Paddle ID or instance UUID."
/>
<AccountSearch />
</div>
);
+6 -1
View File
@@ -5,6 +5,7 @@ import { useState } from "react";
import { api } from "@/lib/api";
import { formatDate, formatStamp } from "@/lib/format";
import { Field } from "@/components/Field";
import { PageHeader } from "@/components/PageHeader";
export default function AuditPage() {
const [filter, setFilter] = useState("");
@@ -18,7 +19,11 @@ export default function AuditPage() {
return (
<div className="grid gap-6">
<h1 className="text-3xl">Audit</h1>
<PageHeader
title="Audit"
subtitle="Every mutating action across every account, newest first."
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
/>
<Field
label="Filter"
value={filter}
@@ -6,6 +6,7 @@ import Link from "next/link";
import clsx from "clsx";
import { api, type InjectionState } from "@/lib/api";
import { Ledger } from "@/components/Ledger";
import { PageHeader } from "@/components/PageHeader";
import { IssuePanel } from "./IssuePanel";
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
@@ -32,29 +33,40 @@ export default function StaffInstancePage() {
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">{data.instance.name || data.instance.instance_id}</h1>
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">
{data.instance.instance_id}
</p>
<p className="text-[0.82rem]">
<Link
href={`/staff/accounts/${data.account.account_id}`}
className="text-accent underline"
>
{data.account.name || data.account.account_id}
</Link>
<span className="text-ink-3">
{" "}
· {data.instance.deployment} · {data.instance.status}
{data.instance.relink_count > 0 &&
` · ${data.instance.relink_count} relinks this term`}
</span>
</p>
<div className="grid gap-3">
<PageHeader
back={{
href: `/staff/accounts/${data.account.account_id}`,
label: data.account.name || "Account",
}}
title={data.instance.name || data.instance.instance_id}
subtitle={
<>
<Link
href={`/staff/accounts/${data.account.account_id}`}
className="text-accent underline"
>
{data.account.name || data.account.account_id}
</Link>
<span className="text-ink-3">
{" "}
· {data.instance.deployment} · {data.instance.status}
{data.instance.relink_count > 0 &&
` · ${data.instance.relink_count} relinks this term`}
</span>
</>
}
record={[
{ key: "Instance", value: data.instance.instance_id, copy: true },
...(data.instance.slug
? [{ key: "Slug", value: data.instance.slug }]
: []),
]}
/>
{data.injection.applicable && inj && (
<p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>
)}
</header>
</div>
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">Licence history</h2>
+18 -18
View File
@@ -1,29 +1,29 @@
"use client";
import Link from "next/link";
import { RequireKind } from "@/lib/session";
import { AppBar, type NavLink } from "@/components/AppBar";
const LINKS = [
["/staff", "Operations"],
["/staff/accounts", "Accounts"],
["/staff/licenses", "Licences"],
["/staff/plans", "Plans"],
["/staff/audit", "Audit"],
] as const;
const LINKS: NavLink[] = [
{ href: "/staff", label: "Operations" },
{ href: "/staff/accounts", label: "Accounts" },
{ href: "/staff/licenses", label: "Licences" },
{ href: "/staff/plans", label: "Plans" },
{ href: "/staff/audit", label: "Audit" },
];
export default function StaffLayout({ children }: { children: React.ReactNode }) {
return (
<RequireKind kind="staff">
<nav className="border-b border-rule-soft bg-panel-2">
<div className="mx-auto flex max-w-rail flex-wrap gap-5 px-5 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em]">
{LINKS.map(([href, label]) => (
<Link key={href} href={href} className="text-ink-3 hover:text-accent">
{label}
</Link>
))}
</div>
</nav>
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
<AppBar
links={LINKS}
staff
context={
<span className="rounded-sm border border-accent px-1.5 py-0.5 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-accent">
Staff
</span>
}
/>
<main className="mx-auto max-w-rail px-5 py-7">{children}</main>
</RequireKind>
);
}
@@ -5,6 +5,7 @@ import Link from "next/link";
import { useState } from "react";
import { api, type Tier } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
export default function LicensesPage() {
const [tier, setTier] = useState<"" | Tier>("");
@@ -19,7 +20,11 @@ export default function LicensesPage() {
return (
<div className="grid gap-6">
<h1 className="text-3xl">Licences</h1>
<PageHeader
title="Licences"
subtitle="Append-only. A renewal writes a new row and supersedes the old one."
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
/>
<div className="flex flex-wrap gap-3">
<select
value={tier}
+110 -3
View File
@@ -1,10 +1,15 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { API_BASE, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Queue } from "@/components/Queue";
import { daysRemaining } from "@/lib/format";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { StatePill } from "@/components/StatePill";
import { LinkButton } from "@/components/Button";
import { daysRemaining, formatStamp } from "@/lib/format";
const HOURS_48 = 48 * 3600_000;
@@ -22,6 +27,11 @@ export default function StaffDashboard() {
queryKey: ["instances", "awaiting_link"],
queryFn: () => api.staff.instances({ status: "awaiting_link" }),
});
const audit = useQuery({ queryKey: ["staff-audit"], queryFn: () => api.staff.audit() });
const allInstances = useQuery({
queryKey: ["instances", "all"],
queryFn: () => api.staff.instances(),
});
if (injection.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
@@ -29,14 +39,32 @@ export default function StaffDashboard() {
(i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48,
);
const failed = injection.data?.count ?? 0;
const instances = allInstances.data ?? [];
return (
<div className="grid gap-6">
<h1 className="text-3xl">Operations</h1>
<PageHeader
title="Operations"
subtitle={
failed > 0
? "Injection failures come first — those instances are paying for a licence they have not received."
: "Nothing failing. Queues below are routine chasing."
}
actions={
<LinkButton variant="line" href="/staff/accounts">
Find an account
</LinkButton>
}
record={[{ key: "Checked", value: formatStamp(new Date().toISOString()) }]}
status={failed > 0 ? <StatePill state="expired" /> : <StatePill state="valid" />}
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Queue
title="Failed injections"
tone="expired"
count={injection.data?.count ?? 0}
count={failed}
items={(injection.data?.failed ?? []).slice(0, 4).map((i) => ({
label: i.name || i.instance_id,
href: `/staff/instances/${i.instance_id}`,
@@ -76,6 +104,85 @@ export default function StaffDashboard() {
}))}
/>
</div>
<PageFrame
aside={
<RailCard title="Fleet">
<RailFacts
rows={[
{ label: "Instances", value: instances.length },
{
label: "Cloud",
value: instances.filter((i) => i.deployment === "cloud").length,
},
{
label: "Self-hosted",
value: instances.filter((i) => i.deployment === "self_hosted")
.length,
},
{
label: "Awaiting link",
value: (unlinked.data ?? []).length,
},
]}
/>
<Link
href="/staff/licenses"
className="text-[0.82rem] font-semibold text-accent underline"
>
All licences
</Link>
</RailCard>
}
>
<section className="overflow-hidden rounded border border-rule bg-panel">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft bg-panel-2 px-4 py-3">
<div>
<h2 className="text-[0.95rem]">Recent activity</h2>
<p className="text-[0.8rem] text-ink-2">
Every licence issued, relinked or reaped, newest first.
</p>
</div>
<Link
href="/staff/audit"
className="text-[0.82rem] font-semibold text-accent underline"
>
Full audit
</Link>
</header>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-[0.86rem]">
<thead>
<tr className="border-b border-rule-soft font-mono text-[0.64rem] uppercase tracking-[0.11em] text-ink-3">
<th className="px-4 py-2 font-normal">Time</th>
<th className="px-4 py-2 font-normal">Action</th>
<th className="px-4 py-2 font-normal">Target</th>
<th className="px-4 py-2 font-normal">Actor</th>
</tr>
</thead>
<tbody>
{(audit.data ?? []).slice(0, 12).map((e, n) => (
<tr key={n} className="border-b border-rule-soft last:border-0">
<td className="px-4 py-2.5 font-mono tabular-nums text-ink-2">
{new Date(e.created_at).toISOString().slice(11, 16)}
</td>
<td className="px-4 py-2.5">{e.action}</td>
<td className="px-4 py-2.5 text-ink-2">{e.target ?? "—"}</td>
<td className="px-4 py-2.5 text-ink-3">{e.actor}</td>
</tr>
))}
{audit.data?.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-ink-3">
Nothing yet today.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</PageFrame>
</div>
);
}
+5 -1
View File
@@ -6,6 +6,7 @@ import { api, type Plan } from "@/lib/api";
import { Button } from "@/components/Button";
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
import { limitLabel } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
export default function PlansPage() {
const qc = useQueryClient();
@@ -36,7 +37,10 @@ export default function PlansPage() {
return (
<div className="grid gap-6">
<h1 className="text-3xl">Plans</h1>
<PageHeader
title="Plans"
subtitle="The authoritative tier table. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
/>
{draft && original && (
<ConfirmPlanChange
+11 -13
View File
@@ -1,28 +1,26 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "@/components/Providers";
import { EnvBadge } from "@/components/EnvBadge";
import { THEME_BOOT_SCRIPT } from "@/lib/theme";
export const metadata: Metadata = {
title: "Vantage Licensing",
title: "Vantage HQ",
description: "Licences, instances and billing for Vantage.",
};
/*
* The masthead deliberately does NOT live here. It belongs to the authenticated
* layouts, so /login, /signup, /verify and /accept-invite stop rendering a bar
* whose navigation and account menu they cannot use.
*/
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
{/* Runs before first paint, so a dark-preferring viewer never sees white. */}
<script dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }} />
</head>
<body>
<header className="border-b border-rule bg-panel">
<div className="mx-auto flex max-w-rail flex-wrap items-center justify-between gap-4 px-5 py-4">
<span className="flex items-baseline gap-2 text-[1.16rem] font-extrabold tracking-[-0.02em]">
Vantage
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">
Licensing
</span>
</span>
<EnvBadge />
</div>
</header>
<Providers>{children}</Providers>
</body>
</html>
+149
View File
@@ -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>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { AccountMenu } from "@/components/AccountMenu";
import { EnvBadge } from "@/components/EnvBadge";
export type NavLink = { href: string; label: string };
/*
* One masthead in three zones: who you are acting as, where you can go, and
* which environment you are in.
*
* It replaces a brand bar and a separate nav strip. The nav's active state is
* derived from the pathname rather than hardcoded — the previous customer nav
* marked Overview as current on every page, including the ones that weren't it.
*
* Staff sit on --panel-2 with a chip where the account name goes. web/ is locked
* to dark so this app defaults to light for the same reason CLAUDE.md gives:
* telling two consoles apart before you click Reissue. Staff and customer need
* that distinction from each other too, and one shade plus one chip buys it
* without a second palette.
*/
export function AppBar({
links,
context,
staff = false,
}: {
links: NavLink[];
context?: React.ReactNode;
staff?: boolean;
}) {
const pathname = usePathname();
const isCurrent = (href: string) =>
// The section root matches only exactly; deeper routes match by prefix,
// so /staff/accounts/:id still lights Accounts while /staff/accounts
// does not light Operations.
href === "/" || href === "/staff"
? pathname === href
: pathname === href || pathname.startsWith(`${href}/`);
return (
<header className={`border-b border-rule ${staff ? "bg-panel-2" : "bg-panel"}`}>
<div className="mx-auto grid max-w-rail grid-cols-[auto_1fr_auto] items-center gap-4 px-5 md:gap-7">
<div className="col-start-1 row-start-1 flex min-w-0 items-center gap-3 py-2.5">
<span className="flex items-baseline gap-2 text-[1.16rem] font-extrabold tracking-[-0.02em]">
Vantage
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">
HQ
</span>
</span>
{context && (
<>
<span aria-hidden className="hidden h-[22px] w-px bg-rule sm:block" />
<span className="hidden min-w-0 sm:block">{context}</span>
</>
)}
</div>
<nav
aria-label={staff ? "Staff" : "Account"}
className="col-span-3 col-start-1 row-start-2 flex items-stretch gap-1 overflow-x-auto border-t border-rule-soft md:col-span-1 md:col-start-2 md:row-start-1 md:border-t-0"
>
{links.map((l) => {
const on = isCurrent(l.href);
return (
<Link
key={l.href}
href={l.href}
aria-current={on ? "page" : undefined}
className={`relative inline-flex shrink-0 items-center px-3 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.08em] md:py-0 ${
on
? "font-bold text-accent after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-accent after:content-['']"
: "text-ink-3 hover:text-ink-2"
}`}
>
{l.label}
</Link>
);
})}
</nav>
<div className="col-start-3 row-start-1 flex items-center justify-end gap-2.5 py-2.5">
<span className="hidden sm:block">
<EnvBadge />
</span>
<AccountMenu staff={staff} />
</div>
</div>
</header>
);
}
+47 -15
View File
@@ -1,25 +1,57 @@
import clsx from "clsx";
import Link from "next/link";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "solid" | "line" };
type Variant = "solid" | "line";
/*
* Matches site/'s .btn--solid and .btn--line exactly, including the neutral
* border on the secondary variant. site/ does not have an accent-outlined
* button and this app should not invent one.
*/
export function Button({ variant = "solid", className, ...rest }: Props) {
return (
<button
{...rest}
className={clsx(
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
"transition-[filter,border-color] duration-150 hover:brightness-110",
variant === "solid"
? "border-accent bg-accent text-accent-ink"
: "border-rule bg-panel text-ink hover:border-ink-3",
rest.disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
className,
)}
/>
export function buttonClass(variant: Variant = "solid", disabled = false, className?: string) {
return clsx(
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
"transition-[filter,border-color] duration-150 hover:brightness-110",
variant === "solid"
? "border-accent bg-accent text-accent-ink"
: "border-rule bg-panel text-ink hover:border-ink-3",
disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
className,
);
}
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant };
export function Button({ variant = "solid", className, ...rest }: Props) {
return <button {...rest} className={buttonClass(variant, rest.disabled, className)} />;
}
/*
* A link that looks like a button. It exists so a navigation action never has to
* be an <a> wrapped around a <button> — invalid markup, and it gives screen
* readers two nested controls where the page means one.
*/
export function LinkButton({
href,
variant = "solid",
external,
className,
children,
}: {
href: string;
variant?: Variant;
external?: boolean;
className?: string;
children: React.ReactNode;
}) {
const cls = buttonClass(variant, false, className);
return external ? (
<a href={href} className={cls}>
{children}
</a>
) : (
<Link href={href} className={cls}>
{children}
</Link>
);
}
-131
View File
@@ -1,131 +0,0 @@
"use client";
import Link from "next/link";
import clsx from "clsx";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, type Instance, type License } from "@/lib/api";
import { daysRemaining, formatDate, licenceState } from "@/lib/format";
import { StatePill } from "./StatePill";
const STRIPE = {
valid: "before:bg-valid",
warn: "before:bg-warn",
expired: "before:bg-expired",
none: "before:bg-accent",
} as const;
export function InstanceCard({
instance,
license,
reapAfterDays,
}: {
instance: Instance;
license?: License;
reapAfterDays?: number;
}) {
const state = licenceState(license?.expires_at, Boolean(license));
const days = license ? daysRemaining(license.expires_at) : 0;
const cloud = instance.deployment === "cloud";
const termDays = instance.tier === "free" ? 30 : 365;
const deleteInDays =
license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
const qc = useQueryClient();
const renew = useMutation({
mutationFn: () => api.renewInstance(instance.instance_id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
});
const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
return (
<article
className={clsx(
"relative grid gap-3 rounded border border-rule bg-panel p-4 pl-5",
"before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']",
STRIPE[state],
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-lg">{instance.name || "Unnamed instance"}</h3>
<p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
{cloud ? "Cloud" : "Self-hosted"}
{instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""}
</p>
</div>
<StatePill state={state} />
</div>
{state === "expired" && (
<p className="text-[0.82rem] text-ink-2">
Servers and monitors are still running, and your agents keep their keys. Changes
are disabled until you renew.
</p>
)}
{state === "expired" && deleteInDays !== null && (
<p className="text-[0.82rem] font-semibold text-expired">
{deleteInDays <= 0
? "Scheduled for deletion."
: `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
</p>
)}
{state === "none" && (
<p className="text-[0.82rem] text-ink-2">
You have paid for this but it is not attached to an install yet, so no licence
has been issued. Linking takes a minute.
</p>
)}
{license && state !== "expired" && (
<div className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
<span>{days} days remaining</span>
<div className="h-[3px] overflow-hidden rounded-sm bg-rule-soft">
<div
className={clsx("h-full", state === "warn" ? "bg-warn" : "bg-valid")}
style={{ width: `${Math.max(2, Math.min(100, (days / termDays) * 100))}%` }}
/>
</div>
<span>Renews {formatDate(license.expires_at)}</span>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
{state === "none" ? (
<Link
href="/instances/link"
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
Link an install
</Link>
) : cloud && instance.slug ? (
<a
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
Open {instance.slug}.vantage.hostxtra.co.uk
</a>
) : (
<Link
href={`/instances/${instance.instance_id}`}
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
{state === "expired" ? "Renew and download" : "Licence and download"}
</Link>
)}
{canRenew && (
<button
type="button"
onClick={() => renew.mutate()}
disabled={renew.isPending}
className="justify-self-start rounded bg-accent px-3 py-1.5 text-[0.82rem] font-semibold text-accent-ink"
>
{renew.isPending ? "Renewing…" : "Renew"}
</button>
)}
</div>
</article>
);
}
+279
View File
@@ -0,0 +1,279 @@
"use client";
import Link from "next/link";
import clsx from "clsx";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { api, type Instance, type License } from "@/lib/api";
import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format";
import { StatePill } from "./StatePill";
import { Button, LinkButton } from "./Button";
const STRIPE = {
valid: "before:bg-valid",
warn: "before:bg-warn",
expired: "before:bg-expired",
none: "before:bg-accent",
} as const;
const KEY = (id: string) => `vantage-hq-record-open:${id}`;
/*
* One instance, open or closed.
*
* Closed it is a row — name, tier, host, term bar, state. Open it adds what the
* licence includes, who can sign in, and the actions. Deliberately ONE component
* rather than a card and a detail panel: two components meant a single-instance
* account got a third of a row of summary with its substance a click away, and
* a six-instance account got a grid of summaries with no way to look closer.
*
* It defaults open when it is the only instance or when it needs attention,
* because the thing that needs you is the thing that should be open. A manual
* toggle is remembered per instance and beats the default from then on.
*/
export function InstanceRecord({
instance,
license,
reapAfterDays,
defaultOpen = false,
}: {
instance: Instance;
license?: License;
reapAfterDays?: number;
defaultOpen?: boolean;
}) {
const state = licenceState(license?.expires_at, Boolean(license));
const days = license ? daysRemaining(license.expires_at) : 0;
const cloud = instance.deployment === "cloud";
const termDays = instance.tier === "free" ? 30 : 365;
const deleteInDays =
license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
const saved = localStorage.getItem(KEY(instance.instance_id));
if (saved !== null) setOpen(saved === "1");
}, [instance.instance_id]);
const toggle = () => {
setOpen((v) => {
localStorage.setItem(KEY(instance.instance_id), v ? "0" : "1");
return !v;
});
};
const qc = useQueryClient();
const renew = useMutation({
mutationFn: () => api.renewInstance(instance.instance_id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
});
const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
// Only fetched once the record is open, and only for cloud: a self-hosted
// install manages its own users and the endpoint refuses it.
const members = useQuery({
queryKey: ["members", instance.instance_id],
queryFn: () => api.members(instance.instance_id),
enabled: open && cloud,
});
const panelId = `record-${instance.instance_id}`;
return (
<article
className={clsx(
"relative grid gap-3.5 rounded border border-rule bg-panel p-4 pl-5",
"before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']",
STRIPE[state],
)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-[1.22rem]">{instance.name || "Unnamed instance"}</h2>
<p className="mt-1 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
{cloud ? "Cloud" : "Self-hosted"}
{instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""}
{` · created ${formatDate(instance.created_at)}`}
</p>
{cloud && instance.slug && (
<a
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
className="mt-1.5 inline-block font-mono text-[0.78rem] text-accent underline"
>
{instance.slug}.vantage.hostxtra.co.uk &rarr;
</a>
)}
</div>
<div className="flex shrink-0 items-center gap-2.5">
<StatePill state={state} />
<button
type="button"
onClick={toggle}
aria-expanded={open}
aria-controls={panelId}
aria-label={open ? "Hide details" : "Show details"}
className="grid h-[26px] w-[26px] place-items-center rounded-sm border border-rule bg-panel text-[0.6rem] text-ink-3 hover:border-accent hover:text-accent"
>
<span
aria-hidden
className={clsx("block transition-transform", open && "rotate-180")}
>
</span>
</button>
</div>
</div>
{license && state !== "expired" && (
<div className="grid max-w-md gap-1.5">
<div className="flex justify-between font-mono text-[0.78rem] tabular-nums text-ink-2">
<span>{days} days remaining</span>
<span>Renews {formatDate(license.expires_at)}</span>
</div>
<div className="h-1 overflow-hidden rounded-sm bg-rule-soft">
<div
className={clsx("h-full", state === "warn" ? "bg-warn" : "bg-valid")}
style={{
width: `${Math.max(2, Math.min(100, (days / termDays) * 100))}%`,
}}
/>
</div>
</div>
)}
{state === "expired" && (
<div className="grid gap-1">
<p className="text-[0.82rem] text-ink-2">
Servers and monitors are still running, and your agents keep their keys.
Changes are disabled until you renew.
</p>
{deleteInDays !== null && (
<p className="text-[0.82rem] font-semibold text-expired">
{deleteInDays <= 0
? "Scheduled for deletion."
: `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
</p>
)}
</div>
)}
{state === "none" && (
<p className="text-[0.82rem] text-ink-2">
You have paid for this but it is not attached to an install yet, so no licence
has been issued. Linking takes a minute.
</p>
)}
<div id={panelId} hidden={!open} className="grid gap-3.5">
{license && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">
Included in {instance.tier?.replace("_", " ") ?? "this licence"}
</p>
<div className="flex flex-wrap gap-x-7 gap-y-2.5">
<Stat n={limitLabel(license.limits.max_servers)} label="Servers" />
<Stat
n={limitLabel(license.limits.max_secret_groups)}
label="Secret groups"
/>
<Stat n={limitLabel(license.limits.max_channels)} label="Channels" />
<Stat
n={license.features.length ? license.features.join(" · ") : "None"}
label="Features"
quiet={license.features.length === 0}
/>
</div>
</div>
)}
{cloud && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">
Who can sign in
</p>
<div className="flex flex-wrap items-center gap-2">
{(members.data ?? []).map((m) => (
<span
key={m.member_id}
className="inline-flex items-center gap-1.5 rounded-full border border-rule-soft py-0.5 pl-0.5 pr-2.5 text-[0.78rem] text-ink-2"
>
<span className="grid h-[18px] w-[18px] place-items-center rounded-full bg-accent font-mono text-[0.56rem] font-bold text-accent-ink">
{m.email.slice(0, 2).toUpperCase()}
</span>
{m.email}
</span>
))}
{members.isLoading && <span className="text-[0.82rem] text-ink-3">Loading</span>}
{members.data?.length === 0 && (
<span className="text-[0.82rem] text-ink-3">Nobody yet.</span>
)}
<Link
href={`/instances/${instance.instance_id}`}
className="text-[0.82rem] font-semibold text-accent underline"
>
Manage access
</Link>
</div>
</div>
)}
<div className="flex flex-wrap items-center gap-2.5">
{state === "none" ? (
<LinkButton href="/instances/link">Link an install</LinkButton>
) : cloud && instance.slug ? (
<>
<LinkButton
external
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
>
Open instance
</LinkButton>
<LinkButton
variant="line"
href={`/instances/${instance.instance_id}`}
>
Instance settings
</LinkButton>
</>
) : (
<LinkButton href={`/instances/${instance.instance_id}`}>
{state === "expired" ? "Renew and download" : "Licence and download"}
</LinkButton>
)}
{canRenew && (
<Button
type="button"
variant="line"
onClick={() => renew.mutate()}
disabled={renew.isPending}
>
{renew.isPending ? "Renewing…" : "Renew"}
</Button>
)}
</div>
</div>
</article>
);
}
function Stat({ n, label, quiet }: { n: string; label: string; quiet?: boolean }) {
return (
<div className="grid gap-px">
<b
className={clsx(
"tabular-nums tracking-[-0.02em]",
quiet
? "text-[0.95rem] font-semibold text-ink-3"
: "text-[1.18rem] font-extrabold",
)}
>
{n}
</b>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.1em] text-ink-3">
{label}
</span>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
/*
* Main column plus a fixed support rail.
*
* The rail is what stops a page being empty and the main column is what stops
* it being thin: an account with one instance used to render a third of a row
* of summary and nothing else. The rail carries what is true regardless of how
* many instances exist, so the page has a floor.
*
* It collapses below lg in source order, which puts the main column first on a
* phone. Nothing is hidden at any width — if content only fits on a desktop it
* does not belong in the rail.
*/
export function PageFrame({
children,
aside,
}: {
children: React.ReactNode;
aside?: React.ReactNode;
}) {
if (!aside) return <div className="grid gap-5">{children}</div>;
return (
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid min-w-0 gap-4">{children}</div>
<aside className="grid gap-3.5">{aside}</aside>
</div>
);
}
/** One card in the rail. Title is a label, not a heading you read for pleasure. */
export function RailCard({
title,
count,
children,
}: {
title: string;
count?: number | string;
children: React.ReactNode;
}) {
return (
<section className="grid gap-2.5 rounded border border-rule bg-panel p-3.5">
<header className="flex items-baseline justify-between gap-2.5">
<h2 className="font-mono text-[0.66rem] font-normal uppercase tracking-[0.12em] text-ink-3">
{title}
</h2>
{count !== undefined && (
<b className="text-[0.95rem] font-extrabold tabular-nums">{count}</b>
)}
</header>
{children}
</section>
);
}
/** Key/value rows for the rail. Values are mono so numbers line up. */
export function RailFacts({ rows }: { rows: { label: string; value: React.ReactNode }[] }) {
return (
<dl className="grid gap-1.5">
{rows.map((r) => (
<div key={r.label} className="flex justify-between gap-2.5 text-[0.82rem]">
<dt className="text-ink-3">{r.label}</dt>
<dd className="m-0 truncate font-mono text-[0.78rem] tabular-nums text-ink">
{r.value}
</dd>
</div>
))}
</dl>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import Link from "next/link";
import { useState } from "react";
/*
* One record-line entry. `copy` marks the value as worth lifting to the
* clipboard — an instance UUID or a licence ID, the strings people paste into
* support tickets.
*/
export type RecordField = { key: string; value: string; copy?: boolean };
function CopyButton({ value }: { value: string }) {
const [done, setDone] = useState(false);
return (
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setDone(true);
setTimeout(() => setDone(false), 1200);
} catch {
// Clipboard is refused without a secure context or a user
// gesture the browser trusts. The value is on screen and
// selectable either way, so this needs no error state.
}
}}
className="rounded-sm border border-rule px-1.5 py-px font-mono text-[0.62rem] uppercase tracking-[0.1em] text-ink-3 hover:border-accent hover:text-accent"
>
{done ? "Copied" : "Copy"}
</button>
);
}
/*
* The page frame every screen starts with, replacing nine hand-rolled header
* blocks that each picked their own gaps and their own place for actions.
*
* The record line is the one new idea: Vantage HQ is a registry, so every screen
* is a record and records have reference numbers. Giving the reference a fixed
* slot, in mono, above the fold, means "where is the ID" stops being a per-page
* question. It costs one hairline rule.
*/
export function PageHeader({
back,
title,
subtitle,
actions,
record,
status,
}: {
back?: { href: string; label: string };
title: string;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
record?: RecordField[];
status?: React.ReactNode;
}) {
return (
<header className="grid gap-3">
{back && (
<Link
href={back.href}
className="justify-self-start font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 hover:text-accent"
>
&larr; {back.label}
</Link>
)}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-[1.9rem]">{title}</h1>
{subtitle && <p className="mt-1 text-[0.92rem] text-ink-2">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
{(record?.length || status) && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 border-t border-rule pt-2.5">
{record?.map((f) => (
<span key={f.key} className="flex items-center gap-2">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">
{f.key}
</span>
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">
{f.value}
</span>
{f.copy && <CopyButton value={f.value} />}
</span>
))}
{status && <span className="ml-auto">{status}</span>}
</div>
)}
</header>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
export type ThemePref = "light" | "dark" | "system";
const KEY = "vantage-hq-theme";
/*
* globals.css has always defined the dark tokens under BOTH
* :root[data-theme="dark"] and :root[data-theme="light"], specifically so an
* in-page control can win over the OS preference in either direction. Nothing
* ever set the attribute. This is that missing half.
*
* "system" removes the attribute rather than writing a value, which hands the
* decision back to the prefers-color-scheme block.
*/
export function applyTheme(pref: ThemePref) {
const root = document.documentElement;
if (pref === "system") root.removeAttribute("data-theme");
else root.setAttribute("data-theme", pref);
}
export function readTheme(): ThemePref {
if (typeof localStorage === "undefined") return "system";
const v = localStorage.getItem(KEY);
return v === "light" || v === "dark" ? v : "system";
}
export function useTheme(): [ThemePref, (p: ThemePref) => void] {
// Starts at "system" on both server and first client render so hydration
// matches; the real value lands in the effect below. The inline script in
// app/layout.tsx has already painted the correct colours by then, so there
// is no flash — only this control's own highlight settles a tick late.
const [pref, setPref] = useState<ThemePref>("system");
useEffect(() => setPref(readTheme()), []);
return [
pref,
(next: ThemePref) => {
setPref(next);
if (next === "system") localStorage.removeItem(KEY);
else localStorage.setItem(KEY, next);
applyTheme(next);
},
];
}
/*
* Runs before first paint, so a dark-preferring user never sees a white flash.
* Inlined as a string because it has to execute ahead of React.
*/
export const THEME_BOOT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(
KEY,
)});if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;