From a5a79f01a792379172bc274096c3e6cab5ff6086 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 11:03:07 -0400 Subject: [PATCH] Scaffold Next.js frontend with generated OpenAPI client and core views Next.js (App Router) + React 19 + TypeScript + Tailwind v4, with shadcn-style UI primitives (Button, Input, Card, Label via cva/tailwind-merge). A typed API client is generated from the backend OpenAPI spec with openapi-typescript + openapi-fetch (npm run gen:api); the committed openapi.json/schema.d.ts are the snapshot. Views: landing, login, register, tree list + create, and tree detail with person list + create. Auth rides the same-origin HttpOnly session cookie the backend sets (Caddy proxies /api/*), so no token handling in JS. Built as a standalone container. Mobile-first; next build is clean. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- frontend/.dockerignore | 7 + frontend/.gitignore | 6 + frontend/Dockerfile | 24 + frontend/app/globals.css | 19 + frontend/app/layout.tsx | 34 + frontend/app/login/page.tsx | 74 ++ frontend/app/page.tsx | 25 + frontend/app/register/page.tsx | 82 ++ frontend/app/trees/[id]/page.tsx | 96 ++ frontend/app/trees/page.tsx | 99 ++ frontend/components/ui/button.tsx | 33 + frontend/components/ui/card.tsx | 24 + frontend/components/ui/input.tsx | 17 + frontend/components/ui/label.tsx | 11 + frontend/lib/api/client.ts | 10 + frontend/lib/api/schema.d.ts | 785 ++++++++++++ frontend/lib/utils.ts | 6 + frontend/next.config.mjs | 7 + frontend/openapi.json | 936 ++++++++++++++ frontend/package-lock.json | 1926 +++++++++++++++++++++++++++++ frontend/package.json | 32 + frontend/postcss.config.mjs | 7 + frontend/public/.gitkeep | 0 frontend/tsconfig.json | 21 + 24 files changed, 4281 insertions(+) create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/login/page.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/app/register/page.tsx create mode 100644 frontend/app/trees/[id]/page.tsx create mode 100644 frontend/app/trees/page.tsx create mode 100644 frontend/components/ui/button.tsx create mode 100644 frontend/components/ui/card.tsx create mode 100644 frontend/components/ui/input.tsx create mode 100644 frontend/components/ui/label.tsx create mode 100644 frontend/lib/api/client.ts create mode 100644 frontend/lib/api/schema.d.ts create mode 100644 frontend/lib/utils.ts create mode 100644 frontend/next.config.mjs create mode 100644 frontend/openapi.json create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/public/.gitkeep create mode 100644 frontend/tsconfig.json diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..463b6d8 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +out +build +npm-debug.log* +.env*.local +README.md diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..06c369c --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/.next +/out +/build +next-env.d.ts +.env*.local diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c2fb58c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:22-bookworm-slim AS runner +WORKDIR /app +ENV NODE_ENV=production \ + PORT=3000 \ + HOSTNAME=0.0.0.0 +# Next standalone output: a minimal server with only the traced dependencies. +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +COPY --from=build /app/public ./public +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..cc6da71 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,19 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #0a0a0a; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..a49acdb --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import "./globals.css"; + +export const metadata: Metadata = { + title: "Provenance", + description: "Where it came from matters — family and land, every fact sourced.", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +
+
+ + Provenance + + +
+
+
{children}
+ + + ); +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..f6e3ec1 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { api } from "@/lib/api/client"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + const { error } = await api.POST("/api/v1/auth/login", { body: { email, password } }); + setLoading(false); + if (error) { + setError("Invalid email or password."); + return; + } + router.push("/trees"); + } + + return ( + + + Sign in + + +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&

{error}

} + +
+

+ No account?{" "} + + Create one + +

+
+
+ ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..cf2db05 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; + +export default function Home() { + return ( +
+
+

Provenance

+

+ Trace where you come from — your family and your land — with every fact linked to a + source, on infrastructure you control. +

+
+
+ + + + + + +
+
+ ); +} diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx new file mode 100644 index 0000000..a580d1d --- /dev/null +++ b/frontend/app/register/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { api } from "@/lib/api/client"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function RegisterPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + const { error } = await api.POST("/api/v1/auth/register", { + body: { email, password, display_name: displayName || null }, + }); + setLoading(false); + if (error) { + setError("Could not register. The email may already be in use, or the password is too short (min 8)."); + return; + } + router.push("/trees"); + } + + return ( + + + Create your account + + +
+
+ + setDisplayName(e.target.value)} /> +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + minLength={8} + required + /> +
+ {error &&

{error}

} + +
+

+ Already have an account?{" "} + + Sign in + +

+
+
+ ); +} diff --git a/frontend/app/trees/[id]/page.tsx b/frontend/app/trees/[id]/page.tsx new file mode 100644 index 0000000..7723848 --- /dev/null +++ b/frontend/app/trees/[id]/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +import { api } from "@/lib/api/client"; +import type { components } from "@/lib/api/schema"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; + +type Person = components["schemas"]["PersonRead"]; + +export default function TreeDetailPage() { + const router = useRouter(); + const params = useParams<{ id: string }>(); + const treeId = params.id; + + const [persons, setPersons] = useState([]); + const [given, setGiven] = useState(""); + const [surname, setSurname] = useState(""); + const [ready, setReady] = useState(false); + + const load = useCallback(async () => { + const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", { + params: { path: { tree_id: treeId } }, + }); + if (response.status === 401) { + router.push("/login"); + return; + } + setPersons(data ?? []); + setReady(true); + }, [router, treeId]); + + useEffect(() => { + load(); + }, [load]); + + async function addPerson(e: React.FormEvent) { + e.preventDefault(); + if (!given.trim() && !surname.trim()) return; + const { error } = await api.POST("/api/v1/trees/{tree_id}/persons", { + params: { path: { tree_id: treeId } }, + body: { given: given || null, surname: surname || null }, + }); + if (!error) { + setGiven(""); + setSurname(""); + load(); + } + } + + if (!ready) return

Loading…

; + + return ( +
+ + ← All trees + + + + + Add a person + + +
+ setGiven(e.target.value)} /> + setSurname(e.target.value)} /> + +
+
+
+ +
+

People

+ {persons.length === 0 ? ( +

No people yet.

+ ) : ( +
    + {persons.map((person) => ( +
  • + + + {person.primary_name ?? Unnamed} + + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/app/trees/page.tsx b/frontend/app/trees/page.tsx new file mode 100644 index 0000000..ef22baa --- /dev/null +++ b/frontend/app/trees/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +import { api } from "@/lib/api/client"; +import type { components } from "@/lib/api/schema"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; + +type Tree = components["schemas"]["TreeRead"]; + +export default function TreesPage() { + const router = useRouter(); + const [trees, setTrees] = useState([]); + const [name, setName] = useState(""); + const [ready, setReady] = useState(false); + + const load = useCallback(async () => { + const { data, response } = await api.GET("/api/v1/trees"); + if (response.status === 401) { + router.push("/login"); + return; + } + setTrees(data ?? []); + setReady(true); + }, [router]); + + useEffect(() => { + load(); + }, [load]); + + async function createTree(e: React.FormEvent) { + e.preventDefault(); + if (!name.trim()) return; + const { error } = await api.POST("/api/v1/trees", { body: { name } }); + if (!error) { + setName(""); + load(); + } + } + + async function logout() { + await api.POST("/api/v1/auth/logout"); + router.push("/login"); + } + + if (!ready) return

Loading…

; + + return ( +
+
+

Your trees

+ +
+ + + + New tree + + +
+ setName(e.target.value)} + /> + +
+
+
+ + {trees.length === 0 ? ( +

No trees yet — create your first one above.

+ ) : ( +
    + {trees.map((tree) => ( +
  • + + + + {tree.name} + + {tree.visibility} + + + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx new file mode 100644 index 0000000..d00fdb9 --- /dev/null +++ b/frontend/components/ui/button.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-neutral-900 text-white hover:bg-neutral-700", + outline: "border border-neutral-300 bg-transparent hover:bg-neutral-100", + ghost: "hover:bg-neutral-100", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 px-3", + }, + }, + defaultVariants: { variant: "default", size: "default" }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +export const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => ( +