feat(adminsite): scaffold and site/'s token system

A fifth Next.js app, built like web/ and site/: App Router, React 18,
Tailwind 3, TanStack Query, standalone output.

app/globals.css carries site/app/globals.css's token blocks copied verbatim
rather than retyped, so the two cannot drift by transcription. Tailwind holds
var() references only -- no component or config entry may contain a hex
value. The semantic three are aliased: site/'s --up/--pend/--down become
valid/warn/expired, so each app names the colours for what it shows.

Unlike web/, there is no rewrite proxy: the browser calls admin directly, so
NEXT_PUBLIC_ADMIN_API_URL must be browser-reachable and listed in admin's
ADMIN_ORIGIN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 20:55:25 +01:00
co-authored by Claude Opus 5
parent 55526263a0
commit 6953f5e972
12 changed files with 9557 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
.next
.env
*.lic
+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
next-env.d.ts
.env
*.lic
+178
View File
@@ -0,0 +1,178 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ==========================================================================
Vantage admin console design tokens.
Lines 8-97 below are site/app/globals.css's token blocks, copied verbatim:
the marketing site and this console are one visual system. Change them in
both apps in the same commit — nothing enforces the match automatically.
Light is the default because web/ is locked to dark, and telling the two
apart at a glance is what stops a Reissue landing in the wrong tab. In dark
mode the accent lifts to #5b9be8, which is nearer web/'s indigo, so the
distinction leans on the ground rather than the hue.
========================================================================== */
:root {
color-scheme: light dark;
--ground: #eaedf3;
--panel: #ffffff;
--panel-2: #f4f6fa;
--ink: #0a1b33;
--ink-2: #41556f;
--ink-3: #6c7f96;
--rule: #cdd6e2;
--rule-soft: #e0e6ef;
--accent: #0b2a58;
--accent-ink: #ffffff;
--up: #2f8a60;
--down: #c6462f;
--pend: #b0801f;
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
--logo: #0b2a58;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--s--1: clamp(0.76rem, 0.74rem + 0.1vw, 0.81rem);
--s-0: clamp(1rem, 0.97rem + 0.14vw, 1.05rem);
--s-1: clamp(1.16rem, 1.09rem + 0.32vw, 1.36rem);
--s-2: clamp(1.5rem, 1.34rem + 0.74vw, 2rem);
--s-3: clamp(2rem, 1.66rem + 1.6vw, 3.1rem);
--s-4: clamp(2.6rem, 1.9rem + 3.3vw, 4.9rem);
--rail: 1200px;
}
/* Dark tokens are defined once and applied through three selectors: the OS
preference, and both explicit values of data-theme so the in-page toggle
wins in either direction. */
@media (prefers-color-scheme: dark) {
:root {
--ground: #071628;
--panel: #0d2138;
--panel-2: #102842;
--ink: #e4ecf6;
--ink-2: #9fb3ca;
--ink-3: #71879f;
--rule: #1e3855;
--rule-soft: #172c44;
--accent: #5b9be8;
--accent-ink: #04101f;
--up: #4fb484;
--down: #e2705a;
--pend: #d6a63f;
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
--logo: #7fb2f0;
}
}
:root[data-theme="dark"] {
--ground: #071628;
--panel: #0d2138;
--panel-2: #102842;
--ink: #e4ecf6;
--ink-2: #9fb3ca;
--ink-3: #71879f;
--rule: #1e3855;
--rule-soft: #172c44;
--accent: #5b9be8;
--accent-ink: #04101f;
--up: #4fb484;
--down: #e2705a;
--pend: #d6a63f;
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
--logo: #7fb2f0;
}
:root[data-theme="light"] {
--ground: #eaedf3;
--panel: #ffffff;
--panel-2: #f4f6fa;
--ink: #0a1b33;
--ink-2: #41556f;
--ink-3: #6c7f96;
--rule: #cdd6e2;
--rule-soft: #e0e6ef;
--accent: #0b2a58;
--accent-ink: #ffffff;
--up: #2f8a60;
--down: #c6462f;
--pend: #b0801f;
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
--logo: #0b2a58;
}
/* Not in site/: the hatched sandbox badge and hover washes need a tinted fill,
and deriving it at each use would drift. */
:root {
--accent-wash: rgba(11, 42, 88, 0.07);
}
@media (prefers-color-scheme: dark) {
:root {
--accent-wash: rgba(91, 155, 232, 0.1);
}
}
:root[data-theme="dark"] {
--accent-wash: rgba(91, 155, 232, 0.1);
}
:root[data-theme="light"] {
--accent-wash: rgba(11, 42, 88, 0.07);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--ground);
color: var(--ink);
font-family: var(--sans);
font-size: var(--s-0);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* site/'s heading treatment, which is what replaces a display face. */
h1,
h2,
h3 {
margin: 0;
font-weight: 800;
line-height: 1.03;
letter-spacing: -0.03em;
text-wrap: balance;
}
p {
margin: 0;
}
code {
font-family: var(--mono);
font-size: 0.92em;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
border-radius: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "@/components/Providers";
import { EnvBadge } from "@/components/EnvBadge";
export const metadata: Metadata = {
title: "Vantage Licensing",
description: "Licences, instances and billing for Vantage.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<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>
);
}
+22
View File
@@ -0,0 +1,22 @@
/*
* Sandbox is hatched as well as coloured, so it survives a colourblind reader
* and a glance. It sits in the same place on every screen: issuing against the
* wrong environment should feel wrong before you click.
*/
const ENV = process.env.NEXT_PUBLIC_ADMIN_ENV === "sandbox" ? "sandbox" : "production";
export function EnvBadge() {
const sandbox = ENV === "sandbox";
return (
<span
className={
sandbox
? "inline-flex items-center gap-2 rounded-sm border border-warn px-2 py-1 font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink [background-image:repeating-linear-gradient(-45deg,var(--accent-wash)_0_6px,transparent_6px_12px)]"
: "inline-flex items-center gap-2 rounded-sm bg-accent px-2 py-1 font-mono text-[0.72rem] uppercase tracking-[0.1em] text-accent-ink"
}
>
<i className="h-1.5 w-1.5 shrink-0 rounded-full bg-current" />
{sandbox ? "Sandbox" : "Production"}
</span>
);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client";
export function Providers({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
+13
View File
@@ -0,0 +1,13 @@
import type { NextConfig } from "next";
/*
* Unlike web/, this app does NOT proxy /api through a rewrite. The browser
* calls admin directly, so NEXT_PUBLIC_ADMIN_API_URL must be reachable from the
* browser and must appear in admin's ADMIN_ORIGIN. lib/api.ts renders an
* explicit not-connected state when it is not.
*/
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
+9186
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "vantage-adminsite",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"next": "16.2.9",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@tanstack/react-query": "^5.51.1",
"clsx": "^2.1.1"
},
"devDependencies": {
"@types/node": "^20.14.11",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@testing-library/jest-dom": "^6.4.8",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "^9.0.0",
"eslint-config-next": "16.2.9",
"jsdom": "^24.1.1",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vitest": "^2.0.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+46
View File
@@ -0,0 +1,46 @@
import type { Config } from "tailwindcss";
/*
* Tokens are shared with site/ — same names, same values, copied verbatim into
* app/globals.css. Nothing here may hold a hex value: if a colour needs to
* change it changes in globals.css, in both apps, in one commit.
*
* The semantic three are aliased rather than renamed. site/ calls them up,
* down and pend because it shows monitor state; this app calls them valid,
* expired and warn because it shows licence state. Same colours, honest names
* on both sides.
*/
const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
ground: "var(--ground)",
panel: "var(--panel)",
"panel-2": "var(--panel-2)",
ink: "var(--ink)",
"ink-2": "var(--ink-2)",
"ink-3": "var(--ink-3)",
rule: "var(--rule)",
"rule-soft": "var(--rule-soft)",
accent: "var(--accent)",
"accent-ink": "var(--accent-ink)",
"accent-wash": "var(--accent-wash)",
valid: "var(--up)",
warn: "var(--pend)",
expired: "var(--down)",
archival: "var(--ink-3)",
},
fontFamily: {
sans: ["ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"],
mono: ["ui-monospace", "Cascadia Mono", "SF Mono", "JetBrains Mono", "Menlo", "Consolas", "monospace"],
},
// site/ uses 4px on panels and buttons, 2px on focus rings.
borderRadius: { DEFAULT: "4px" },
maxWidth: { rail: "1200px" },
},
},
plugins: [],
};
export default config;
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"types": ["vitest/globals", "@testing-library/jest-dom"],
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}