@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Honeypot } from "@/components/Honeypot";
|
||||
import { submitContact, type SubmitResult } from "@/lib/submit";
|
||||
|
||||
export function ContactForm() {
|
||||
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
|
||||
const sending = result.state === "sending";
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
setResult({ state: "sending" });
|
||||
setResult(
|
||||
await submitContact({
|
||||
name: String(data.get("name") ?? ""),
|
||||
email: String(data.get("email") ?? ""),
|
||||
servers: String(data.get("servers") ?? ""),
|
||||
topic: String(data.get("topic") ?? ""),
|
||||
message: String(data.get("message") ?? ""),
|
||||
website: String(data.get("website") ?? ""),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (result.state === "sent") {
|
||||
return (
|
||||
<div role="status">
|
||||
<h2 style={{ fontSize: "var(--s-1)" }}>Message sent.</h2>
|
||||
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
|
||||
We reply within one business day. If it is urgent, email support@hostxtra.co.uk directly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldError = (name: string) => result.fields?.[name];
|
||||
|
||||
return (
|
||||
<form className="form" onSubmit={onSubmit} noValidate>
|
||||
<Honeypot />
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="c-name">Your name</label>
|
||||
<input id="c-name" name="name" type="text" autoComplete="name" required aria-describedby="c-name-err" />
|
||||
{fieldError("name") && (
|
||||
<small id="c-name-err" className="field__err">
|
||||
{fieldError("name")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="c-email">Work email</label>
|
||||
<input id="c-email" name="email" type="email" autoComplete="email" required aria-describedby="c-email-err" />
|
||||
{fieldError("email") && (
|
||||
<small id="c-email-err" className="field__err">
|
||||
{fieldError("email")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="c-servers">Roughly how many servers?</label>
|
||||
<select id="c-servers" name="servers" defaultValue="4–25">
|
||||
<option>1–3</option>
|
||||
<option>4–25</option>
|
||||
<option>26–100</option>
|
||||
<option>More than 100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="c-topic">What is this about?</label>
|
||||
<select id="c-topic" name="topic" defaultValue="Evaluating Vantage">
|
||||
<option>Evaluating Vantage</option>
|
||||
<option>Self-hosted licensing</option>
|
||||
<option>Migrating from something else</option>
|
||||
<option>Security disclosure</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="c-message">What are you trying to solve?</label>
|
||||
<textarea
|
||||
id="c-message"
|
||||
name="message"
|
||||
required
|
||||
placeholder="We inherit client servers and can never prove who still has access…"
|
||||
aria-describedby="c-message-err"
|
||||
/>
|
||||
{fieldError("message") && (
|
||||
<small id="c-message-err" className="field__err">
|
||||
{fieldError("message")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="btn btn--solid" type="submit" style={{ alignSelf: "flex-start" }} disabled={sending}>
|
||||
{sending ? "Sending…" : "Send message"}
|
||||
</button>
|
||||
|
||||
{result.state === "error" && result.message && (
|
||||
<p className="field__err" role="alert">
|
||||
{result.message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Link from "next/link";
|
||||
import { NAV_LINKS } from "@/components/nav-links";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="foot">
|
||||
<div className="rail foot__in">
|
||||
<p>Vantage — self-hosted fleet control for people who own their servers.</p>
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link key={link.href} href={link.href}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<Link href="/start">Create organisation</Link>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* A field no person ever sees or tabs into, but an automated form-filler will
|
||||
* happily complete. The server treats any value here as a bot. Hidden with
|
||||
* inline styles rather than a utility class so it stays hidden even if the
|
||||
* stylesheet fails to load.
|
||||
*/
|
||||
export function Honeypot() {
|
||||
return (
|
||||
<div style={{ position: "absolute", left: "-9999px", width: 1, height: 1, overflow: "hidden" }} aria-hidden="true">
|
||||
<label htmlFor="website">Website</label>
|
||||
<input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" defaultValue="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/*
|
||||
* The hero's signature element: a fleet panel that plays one honest cycle of
|
||||
* what the product actually does — a workflow runs three steps, a TLS monitor
|
||||
* fails and opens an incident, a key revocation lands — then rests. It is a
|
||||
* dramatisation, not live data, so nothing here talks to an API.
|
||||
*/
|
||||
|
||||
type LogLine = { time: string; body: React.ReactNode };
|
||||
|
||||
type Beat = {
|
||||
at: number;
|
||||
line: LogLine;
|
||||
effect?: "incident" | "runDone" | "revoked";
|
||||
};
|
||||
|
||||
const BEATS: Beat[] = [
|
||||
{ at: 600, line: { time: "14:22:02", body: "running · step 1/3 · pull image" } },
|
||||
{ at: 1500, line: { time: "14:22:04", body: "running · step 2/3 · migrate database" } },
|
||||
{
|
||||
at: 2600,
|
||||
line: { time: "14:22:07", body: <><span className="ok">ok</span> · migrate database · exit 0</> },
|
||||
},
|
||||
{
|
||||
at: 3400,
|
||||
line: { time: "14:22:08", body: "running · step 3/3 · restart service" },
|
||||
effect: "incident",
|
||||
},
|
||||
{
|
||||
at: 4300,
|
||||
line: { time: "14:22:10", body: <><span className="er">monitor</span> · edge-gw-02 tls · connection refused</> },
|
||||
},
|
||||
{
|
||||
at: 5200,
|
||||
line: { time: "14:22:11", body: <><span className="ok">ok</span> · restart service · exit 0</> },
|
||||
effect: "runDone",
|
||||
},
|
||||
{
|
||||
at: 6000,
|
||||
line: { time: "14:22:12", body: <>run finished · <span className="ok">success</span> · 3 steps · 1 server</> },
|
||||
effect: "revoked",
|
||||
},
|
||||
];
|
||||
|
||||
const FIRST_LINE: LogLine = { time: "14:22:01", body: "queued · deploy-app · 1 server" };
|
||||
const MAX_LINES = 7;
|
||||
|
||||
export function InstrumentPanel() {
|
||||
const [lines, setLines] = useState<LogLine[]>([FIRST_LINE]);
|
||||
const [incident, setIncident] = useState(false);
|
||||
const [runActive, setRunActive] = useState(true);
|
||||
const [revoked, setRevoked] = useState(false);
|
||||
const [resting, setResting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const apply = (effect: Beat["effect"]) => {
|
||||
if (effect === "incident") setIncident(true);
|
||||
if (effect === "runDone") setRunActive(false);
|
||||
if (effect === "revoked") setRevoked(true);
|
||||
};
|
||||
|
||||
// Reduced motion gets the finished state immediately rather than no state.
|
||||
if (reduced) {
|
||||
setLines([FIRST_LINE, ...BEATS.map((b) => b.line)].slice(-MAX_LINES));
|
||||
BEATS.forEach((b) => apply(b.effect));
|
||||
setResting(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timers = BEATS.map((beat) =>
|
||||
window.setTimeout(() => {
|
||||
setLines((prev) => [...prev, beat.line].slice(-MAX_LINES));
|
||||
apply(beat.effect);
|
||||
}, beat.at)
|
||||
);
|
||||
timers.push(window.setTimeout(() => setResting(true), 6600));
|
||||
|
||||
return () => timers.forEach(window.clearTimeout);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="instrument">
|
||||
<div className="rail">
|
||||
<div className="instrument__bar">
|
||||
<span>
|
||||
<b>northgate</b> · fleet
|
||||
</span>
|
||||
<span>12 servers</span>
|
||||
<span>{incident ? "10 up" : "11 up"}</span>
|
||||
<span>{incident ? "2 down" : "1 down"}</span>
|
||||
<span>3 monitors</span>
|
||||
<span>{runActive ? "1 run active" : "no runs active"}</span>
|
||||
<span className="instrument__clock">14:22:12 UTC</span>
|
||||
</div>
|
||||
|
||||
<div className="panes">
|
||||
<section className="pane" aria-label="Fleet status">
|
||||
<h2 className="pane__h">
|
||||
Fleet <span>{revoked ? "key revoked · 1 server updated" : "agents polling"}</span>
|
||||
</h2>
|
||||
|
||||
<Row host="proxmox-node-1" sub="4 keys · 12% cpu" state="up" label="Active" />
|
||||
<Row host="db-primary" sub="3 keys · 61% cpu" state="up" label="Active" />
|
||||
<Row
|
||||
host="edge-gw-02"
|
||||
sub={incident ? "2 keys · tls refused" : "2 keys · tls 41d"}
|
||||
state={incident ? "down" : "up"}
|
||||
label={incident ? "Incident" : "Active"}
|
||||
/>
|
||||
<Row host="win-build-01" sub="agent 1.4.1 · update ready" state="pend" label="Pending" />
|
||||
<Row
|
||||
host="app-worker-03"
|
||||
sub={revoked ? "4 keys · 1 revoked" : "5 keys · idle"}
|
||||
state="up"
|
||||
label="Active"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="pane" aria-label="Workflow run">
|
||||
<h2 className="pane__h">
|
||||
Run <span>run_8f31c2</span>
|
||||
</h2>
|
||||
<div className="stream" aria-live="polite">
|
||||
{lines.map((line, i) => (
|
||||
<div key={`${line.time}-${i}`}>
|
||||
<span className="t">{line.time}</span> {line.body}
|
||||
</div>
|
||||
))}
|
||||
{resting && (
|
||||
<div>
|
||||
<span className="caret">_</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
host,
|
||||
sub,
|
||||
state,
|
||||
label,
|
||||
}: {
|
||||
host: string;
|
||||
sub: string;
|
||||
state: "up" | "down" | "pend";
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="frow">
|
||||
<span className={state === "up" ? "dot" : `dot dot--${state}`} />
|
||||
<span className="frow__host">{host}</span>
|
||||
<span className="frow__sub">{sub}</span>
|
||||
<span className={`chip chip--${state}`}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Traced from web/public/images/vantage_logo.svg. The fill is currentColor so
|
||||
// the mark follows the --logo token in both themes.
|
||||
export function Logo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="246 207 533 610" className={className} aria-hidden="true" focusable="false">
|
||||
<g transform="translate(0,1024) scale(0.1,-0.1)" fill="currentColor" stroke="none">
|
||||
<path d="M4940 7767 c-96 -57 -528 -312 -960 -567 -678 -400 -1064 -628 -1187 -702 l-33 -20 0 -1357 0 -1357 293 -174 c160 -96 425 -252 587 -348 946 -559 1352 -799 1407 -833 34 -22 67 -39 72 -39 5 0 188 106 408 236 219 130 459 272 533 315 74 44 425 252 780 462 l645 382 0 1355 0 1355 -135 81 c-140 84 -1118 662 -1812 1070 -218 129 -402 236 -410 239 -7 3 -92 -42 -188 -98z m297 -331 c309 -182 971 -572 1208 -712 149 -88 372 -220 498 -294 l227 -135 0 -1175 0 -1175 -578 -342 c-317 -188 -694 -411 -837 -495 -143 -85 -344 -204 -447 -265 l-186 -111 -314 185 c-987 584 -1710 1013 -1725 1025 -10 8 -13 256 -13 1178 0 1099 1 1168 18 1182 9 8 150 93 312 188 162 96 417 246 565 333 805 476 1149 677 1156 677 4 0 56 -29 116 -64z M4520 6930 c-157 -93 -432 -256 -612 -362 -180 -105 -325 -195 -322 -199 2 -4 29 -21 59 -38 l55 -31 177 0 178 0 235 140 c129 77 299 177 377 222 l143 83 0 178 c0 97 -1 177 -2 177 -2 -1 -131 -77 -288 -170z M5430 6927 c0 -128 3 -177 13 -185 6 -5 77 -48 157 -95 80 -46 245 -143 366 -216 l221 -131 177 0 176 0 55 31 c30 17 57 35 60 39 3 5 -32 30 -77 56 -79 45 -673 396 -988 583 -80 47 -148 88 -152 89 -5 2 -8 -75 -8 -171z M5050 6562 c-424 -251 -560 -334 -560 -342 0 -5 33 -79 73 -165 52 -112 76 -154 87 -152 8 2 118 65 243 140 l228 135 77 -45 c251 -150 397 -233 404 -231 7 3 122 238 148 304 8 20 -15 36 -303 205 -171 101 -316 185 -321 187 -6 1 -40 -15 -76 -36z M3760 6109 c0 -6 187 -396 417 -867 229 -471 483 -994 564 -1162 l148 -305 232 0 233 0 271 560 c150 308 403 830 564 1160 160 329 291 605 291 612 0 19 -553 19 -568 1 -9 -12 -229 -480 -652 -1390 -73 -158 -136 -285 -140 -283 -3 2 -100 206 -215 452 -115 246 -291 624 -390 838 l-182 390 -287 3 c-202 2 -286 -1 -286 -9z M3270 5136 c0 -382 -3 -701 -6 -710 -4 -9 -1 -16 6 -16 9 0 136 72 278 157 l22 13 0 539 0 539 -142 82 c-79 46 -146 85 -150 87 -5 2 -8 -308 -8 -691z M6880 5779 c-47 -28 -113 -66 -147 -86 l-63 -35 0 -537 0 -538 142 -84 c78 -46 147 -85 153 -87 7 -2 10 221 10 708 0 390 -2 710 -5 710 -3 0 -43 -23 -90 -51z M3851 4935 l-1 -550 183 -107 c100 -60 268 -159 372 -222 105 -63 191 -113 193 -111 4 3 -274 573 -288 590 -6 8 -32 26 -56 40 l-44 26 0 76 0 76 -112 231 c-62 126 -143 291 -180 365 l-67 136 0 -550z M6380 5471 c0 -5 -70 -152 -156 -327 -203 -414 -194 -393 -194 -473 l0 -68 -51 -34 c-50 -34 -52 -38 -190 -323 -77 -159 -138 -290 -136 -292 2 -3 127 69 278 158 151 90 316 188 367 218 l92 54 0 548 c0 301 -2 548 -5 548 -3 0 -5 -4 -5 -9z M3722 3952 l-144 -86 49 -28 c26 -16 111 -66 188 -112 136 -80 526 -311 830 -492 83 -49 153 -91 158 -92 4 -2 6 76 5 174 l-3 178 -70 41 c-38 23 -246 146 -461 273 -216 128 -396 232 -400 231 -5 -1 -73 -40 -152 -87z M6310 4011 c-25 -16 -124 -74 -220 -131 -96 -57 -284 -168 -417 -247 l-243 -145 0 -174 c0 -96 2 -174 5 -174 2 0 37 20 77 44 40 24 195 116 343 204 149 87 369 217 490 289 121 72 241 142 268 157 26 16 47 29 47 31 0 6 -292 176 -301 174 -2 0 -24 -13 -49 -28z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { NAV_LINKS } from "@/components/nav-links";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
|
||||
export function Nav() {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// A route change should never leave the drawer hanging open behind the new page.
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
function current(href: string) {
|
||||
return pathname === href || pathname === `${href}/` ? "page" : undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="nav">
|
||||
<div className="rail nav__in">
|
||||
<Link className="brand" href="/">
|
||||
<Logo />
|
||||
<b>Vantage</b>
|
||||
</Link>
|
||||
|
||||
<nav className="nav__links" aria-label="Main">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link key={link.href} href={link.href} aria-current={current(link.href)}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn nav__menu"
|
||||
aria-expanded={open}
|
||||
aria-controls="nav-drawer"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{open ? "Close" : "Menu"}
|
||||
</button>
|
||||
|
||||
<Link className="btn btn--solid btn--sm nav__cta" href="/start">
|
||||
Create organisation
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{open && (
|
||||
<div className="drawer" id="nav-drawer">
|
||||
<div className="rail">
|
||||
<nav aria-label="Main, mobile">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link key={link.href} href={link.href} aria-current={current(link.href)}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<Link className="btn btn--solid" href="/start">
|
||||
Create organisation
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Honeypot } from "@/components/Honeypot";
|
||||
import { submitSignup, type SubmitResult } from "@/lib/submit";
|
||||
|
||||
const MIN_PASSWORD = 12;
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export function OrgForm() {
|
||||
const [slug, setSlug] = useState("");
|
||||
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
|
||||
const sending = result.state === "sending";
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
setResult({ state: "sending" });
|
||||
setResult(
|
||||
await submitSignup({
|
||||
org_name: String(data.get("org_name") ?? ""),
|
||||
email: String(data.get("email") ?? ""),
|
||||
password: String(data.get("password") ?? ""),
|
||||
website: String(data.get("website") ?? ""),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (result.state === "sent") {
|
||||
return (
|
||||
<div role="status">
|
||||
<h2 style={{ fontSize: "var(--s-1)" }}>Check your email.</h2>
|
||||
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
|
||||
We sent a confirmation link. Open it and <b>{slug || "your organisation"}</b> is created with you as its
|
||||
owner. The link works once and expires in 24 hours.
|
||||
</p>
|
||||
<p style={{ color: "var(--ink-3)", marginTop: "0.75rem", fontSize: "0.88rem" }}>
|
||||
Nothing exists until you confirm — if the email does not arrive, start again or contact
|
||||
support@hostxtra.co.uk.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldError = (name: string) => result.fields?.[name];
|
||||
|
||||
return (
|
||||
<form className="form" onSubmit={onSubmit} noValidate>
|
||||
<Honeypot />
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="o-org">Organisation name</label>
|
||||
<input
|
||||
id="o-org"
|
||||
name="org_name"
|
||||
type="text"
|
||||
placeholder="Northgate Systems"
|
||||
required
|
||||
onChange={(e) => setSlug(slugify(e.target.value))}
|
||||
aria-describedby="o-org-err"
|
||||
/>
|
||||
<span className="hostline">
|
||||
<b>{slug || "your-org"}</b>.vantage.sh
|
||||
</span>
|
||||
{fieldError("org_name") && (
|
||||
<small id="o-org-err" className="field__err">
|
||||
{fieldError("org_name")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="o-email">Owner email</label>
|
||||
<input id="o-email" name="email" type="email" autoComplete="email" required aria-describedby="o-email-err" />
|
||||
<small>You become the first owner and can invite the rest of the team afterwards.</small>
|
||||
{fieldError("email") && (
|
||||
<small id="o-email-err" className="field__err">
|
||||
{fieldError("email")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="o-pass">Password</label>
|
||||
<input
|
||||
id="o-pass"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={MIN_PASSWORD}
|
||||
required
|
||||
aria-describedby="o-pass-err"
|
||||
/>
|
||||
<small>At least {MIN_PASSWORD} characters. Use a manager — you are about to manage SSH keys with it.</small>
|
||||
{fieldError("password") && (
|
||||
<small id="o-pass-err" className="field__err">
|
||||
{fieldError("password")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="btn btn--solid" type="submit" style={{ alignSelf: "flex-start" }} disabled={sending}>
|
||||
{sending ? "Sending…" : "Create organisation"}
|
||||
</button>
|
||||
|
||||
{result.state === "error" && result.message && (
|
||||
<p className="field__err" role="alert">
|
||||
{result.message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Applies the stored theme before first paint. Without this the page renders in
|
||||
// the OS theme for a frame and then snaps to the stored one.
|
||||
const script = `
|
||||
(function(){
|
||||
try {
|
||||
var t = localStorage.getItem("vantage-theme");
|
||||
if (t === "light" || t === "dark") {
|
||||
document.documentElement.setAttribute("data-theme", t);
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export function ThemeScript() {
|
||||
return <script dangerouslySetInnerHTML={{ __html: script }} />;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "vantage-theme";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
function currentTheme(): Theme {
|
||||
const set = document.documentElement.getAttribute("data-theme");
|
||||
if (set === "light" || set === "dark") return set;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(currentTheme());
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
const next: Theme = currentTheme() === "dark" ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
setTheme(next);
|
||||
}
|
||||
|
||||
// Until mounted the rendered label would disagree with the server output, so
|
||||
// the button carries a neutral label on first paint.
|
||||
const label = theme === null ? "Theme" : theme === "dark" ? "Light" : "Dark";
|
||||
|
||||
return (
|
||||
<button type="button" className="icon-btn" onClick={toggle} aria-label={`Switch to ${label.toLowerCase()} theme`}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const NAV_LINKS = [
|
||||
{ href: "/platform", label: "Platform" },
|
||||
{ href: "/pricing", label: "Pricing" },
|
||||
{ href: "/about", label: "About" },
|
||||
{ href: "/contact", label: "Contact" },
|
||||
];
|
||||
Reference in New Issue
Block a user