Files
provenance/frontend/app/trees/[id]/page.tsx
T
justin a5a79f01a7 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) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 11:03:07 -04:00

97 lines
2.9 KiB
TypeScript

"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<Person[]>([]);
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 <p className="text-neutral-500">Loading</p>;
return (
<div className="space-y-6">
<Link href="/trees" className="text-sm text-neutral-500 hover:underline">
All trees
</Link>
<Card>
<CardHeader>
<CardTitle className="text-base">Add a person</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={addPerson} className="flex gap-2">
<Input placeholder="Given name" value={given} onChange={(e) => setGiven(e.target.value)} />
<Input placeholder="Surname" value={surname} onChange={(e) => setSurname(e.target.value)} />
<Button type="submit">Add</Button>
</form>
</CardContent>
</Card>
<div>
<h2 className="mb-2 text-lg font-semibold">People</h2>
{persons.length === 0 ? (
<p className="text-neutral-500">No people yet.</p>
) : (
<ul className="space-y-2">
{persons.map((person) => (
<li key={person.id}>
<Card>
<CardContent className="p-4">
{person.primary_name ?? <span className="text-neutral-400">Unnamed</span>}
</CardContent>
</Card>
</li>
))}
</ul>
)}
</div>
</div>
);
}