Phase 0: scaffold Next.js, Tailwind tokens, Supabase clients, env validation
Next.js 16 App Router with strict TypeScript, Tailwind 4 theme tokens from docs/UI.md, Vitest, and the three Supabase clients. Env vars are validated with zod in next.config.ts so dev and build fail fast with a readable error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Design tokens from docs/UI.md. */
|
||||
:root {
|
||||
--stage: #1b1640;
|
||||
--floodlight: #ffd23f;
|
||||
--chalk: #f5f7fa;
|
||||
--ink: #14213d;
|
||||
--signal: #2ec4b6;
|
||||
--alert: #e63946;
|
||||
}
|
||||
|
||||
/* Expose the tokens as Tailwind utilities: bg-stage, text-ink, font-display, ... */
|
||||
@theme inline {
|
||||
--color-stage: var(--stage);
|
||||
--color-floodlight: var(--floodlight);
|
||||
--color-chalk: var(--chalk);
|
||||
--color-ink: var(--ink);
|
||||
--color-signal: var(--signal);
|
||||
--color-alert: var(--alert);
|
||||
--font-display: var(--font-big-shoulders), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans: var(--font-atkinson), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--chalk);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--ink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Atkinson_Hyperlegible, Big_Shoulders } from "next/font/google";
|
||||
|
||||
import { publicEnv } from "@/lib/env.public";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
// Google Fonts renamed "Big Shoulders Display" to "Big Shoulders"; same typeface.
|
||||
const bigShoulders = Big_Shoulders({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-big-shoulders",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const atkinson = Atkinson_Hyperlegible({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "700"],
|
||||
variable: "--font-atkinson",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: publicEnv.NEXT_PUBLIC_APP_NAME,
|
||||
description: "Live scoring for game nights: control room, projector scoreboard and judge links.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html lang="en" className={`${bigShoulders.variable} ${atkinson.variable}`}>
|
||||
<body className="min-h-dvh font-sans antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { publicEnv } from "@/lib/env.public";
|
||||
|
||||
const tokens = [
|
||||
{ name: "Stage", className: "bg-stage" },
|
||||
{ name: "Floodlight", className: "bg-floodlight" },
|
||||
{ name: "Chalk", className: "bg-chalk" },
|
||||
{ name: "Ink", className: "bg-ink" },
|
||||
{ name: "Signal", className: "bg-signal" },
|
||||
{ name: "Alert", className: "bg-alert" },
|
||||
];
|
||||
|
||||
// Placeholder home for Phase 0: proves fonts, tokens and env loading work.
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col justify-center gap-8 p-6">
|
||||
<div>
|
||||
<h1 className="font-display text-6xl font-bold tabular-nums">
|
||||
{publicEnv.NEXT_PUBLIC_APP_NAME}
|
||||
</h1>
|
||||
<p className="mt-2 text-lg">Scaffold is running. Admin, scoreboard and judge pages come next.</p>
|
||||
</div>
|
||||
<ul className="grid grid-cols-3 gap-3 sm:grid-cols-6">
|
||||
{tokens.map((token) => (
|
||||
<li key={token.name} className="text-sm">
|
||||
<div className={`h-12 rounded border border-ink/20 ${token.className}`} />
|
||||
{token.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { parseEnv, publicEnvSchema } from "./env";
|
||||
|
||||
// Each var is referenced literally so Next.js can inline it into the client bundle.
|
||||
export const publicEnv = parseEnv(publicEnvSchema, {
|
||||
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
|
||||
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
import { parseEnv, serverEnvSchema } from "./env";
|
||||
|
||||
export const serverEnv = parseEnv(serverEnvSchema, process.env);
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseEnv, serverEnvSchema } from "./env";
|
||||
|
||||
const valid = {
|
||||
NEXT_PUBLIC_SUPABASE_URL: "https://example.supabase.co",
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: "anon",
|
||||
NEXT_PUBLIC_SITE_URL: "http://localhost:3000",
|
||||
SUPABASE_SERVICE_ROLE_KEY: "service",
|
||||
ADMIN_EMAILS: "Host@Example.com, second@example.com",
|
||||
};
|
||||
|
||||
describe("parseEnv", () => {
|
||||
it("parses a valid env and normalises admin emails", () => {
|
||||
const env = parseEnv(serverEnvSchema, valid);
|
||||
expect(env.ADMIN_EMAILS).toEqual(["host@example.com", "second@example.com"]);
|
||||
expect(env.NEXT_PUBLIC_APP_NAME).toBe("Game Night Live");
|
||||
});
|
||||
|
||||
it("names every missing or blank variable in one error", () => {
|
||||
const broken = { ...valid, SUPABASE_SERVICE_ROLE_KEY: undefined, ADMIN_EMAILS: " " };
|
||||
expect(() => parseEnv(serverEnvSchema, broken)).toThrowError(
|
||||
/SUPABASE_SERVICE_ROLE_KEY[\s\S]*ADMIN_EMAILS[\s\S]*\.env\.local/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a site URL with a trailing slash", () => {
|
||||
const broken = { ...valid, NEXT_PUBLIC_SITE_URL: "http://localhost:3000/" };
|
||||
expect(() => parseEnv(serverEnvSchema, broken)).toThrowError(/NEXT_PUBLIC_SITE_URL/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Schemas only — no values are read here, so this file is safe to import from
|
||||
// next.config.ts, tests, client and server code alike.
|
||||
|
||||
export const publicEnvSchema = z.object({
|
||||
NEXT_PUBLIC_SUPABASE_URL: z.url(),
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
|
||||
NEXT_PUBLIC_APP_NAME: z.string().min(1).default("Game Night Live"),
|
||||
NEXT_PUBLIC_SITE_URL: z
|
||||
.url()
|
||||
.refine((url) => !url.endsWith("/"), "must not end with a slash"),
|
||||
});
|
||||
|
||||
export const serverEnvSchema = publicEnvSchema.extend({
|
||||
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
|
||||
ADMIN_EMAILS: z
|
||||
.string()
|
||||
.transform((value) =>
|
||||
value
|
||||
.split(",")
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
)
|
||||
.pipe(z.array(z.email()).min(1, "needs at least one email")),
|
||||
});
|
||||
|
||||
export type PublicEnv = z.infer<typeof publicEnvSchema>;
|
||||
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
||||
|
||||
type EnvSource = Record<string, string | undefined>;
|
||||
|
||||
/** Parses env vars, throwing one readable error that lists every problem. */
|
||||
export function parseEnv<T extends z.ZodType>(schema: T, source: EnvSource): z.infer<T> {
|
||||
// Treat blank values ("FOO=") the same as missing ones.
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(source).filter(([, value]) => value !== undefined && value.trim() !== ""),
|
||||
);
|
||||
const result = schema.safeParse(cleaned);
|
||||
if (!result.success) {
|
||||
const problems = result.error.issues
|
||||
.map((issue) => {
|
||||
const name = issue.path.join(".") || "(env)";
|
||||
return ` - ${name}: ${name in cleaned ? issue.message : "is missing"}`;
|
||||
})
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`Invalid or missing environment variables:\n${problems}\n` +
|
||||
"Copy .env.example to .env.local and fill in the values.",
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import "server-only";
|
||||
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
import { serverEnv } from "@/lib/env.server";
|
||||
|
||||
/**
|
||||
* Service-role client. Bypasses RLS, so only call it from route handlers
|
||||
* AFTER the scoreboard/judge token has been validated, and scope every query by that token.
|
||||
*/
|
||||
export function createAdminClient() {
|
||||
return createClient(serverEnv.NEXT_PUBLIC_SUPABASE_URL, serverEnv.SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
import { publicEnv } from "@/lib/env.public";
|
||||
|
||||
/** Anon-key client for the browser: Realtime subscriptions + the admin auth session. */
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
publicEnv.NEXT_PUBLIC_SUPABASE_URL,
|
||||
publicEnv.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import "server-only";
|
||||
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { publicEnv } from "@/lib/env.public";
|
||||
|
||||
/** Cookie-based client for admin server components/actions. RLS applies as the signed-in admin. */
|
||||
export async function createClient() {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return createServerClient(
|
||||
publicEnv.NEXT_PUBLIC_SUPABASE_URL,
|
||||
publicEnv.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options),
|
||||
);
|
||||
} catch {
|
||||
// Server Components can't write cookies. Safe to ignore: the proxy
|
||||
// refreshes the session cookie on every request.
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user