diff --git a/src/app/api/character/[id]/route.ts b/src/app/api/character/[id]/route.ts new file mode 100644 index 0000000..cd8b31b --- /dev/null +++ b/src/app/api/character/[id]/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import clientPromise from "@/lib/mongodb"; + +const DB = "dndextended"; + +/** + * Public read endpoint. Anyone with the URL can view the character sheet. + * Bundles all referenced content docs so the client renders in a single fetch. + * Only referenced content is returned — no full lists exposed. + */ +export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) { + const { id } = await ctx.params; + if (!id) return NextResponse.json({ error: "id required" }, { status: 400 }); + + const client = await clientPromise; + const db = client.db(DB); + + const character = await db.collection("characters").findOne({ id }); + if (!character) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const speciesIds = [character.speciesId].filter(Boolean); + const classIds = (character.classes ?? []).map((c: { classId: string }) => c.classId).filter(Boolean); + const subclassIds = (character.classes ?? []).map((c: { subclassId?: string }) => c.subclassId).filter(Boolean); + const backgroundIds = [character.backgroundId].filter(Boolean); + const spellIds = Object.values(character.magic?.spells ?? {}).flat() as string[]; + + const [speciesDocs, classDocs, subclassDocs, backgroundDocs, spellDocs] = await Promise.all([ + speciesIds.length ? db.collection("species").find({ id: { $in: speciesIds } }).toArray() : [], + classIds.length ? db.collection("classes").find({ id: { $in: classIds } }).toArray() : [], + subclassIds.length ? db.collection("subclasses").find({ id: { $in: subclassIds } }).toArray() : [], + backgroundIds.length ? db.collection("backgrounds").find({ id: { $in: backgroundIds } }).toArray() : [], + spellIds.length ? db.collection("spells").find({ id: { $in: spellIds } }).toArray() : [], + ]); + + // Collect every feature id referenced by species, classes, subclasses, backgrounds, applied. + const featureIds = new Set(); + for (const s of speciesDocs) for (const fid of s.featureIds ?? []) featureIds.add(fid); + for (const b of backgroundDocs) for (const fid of b.featureIds ?? []) featureIds.add(fid); + for (const c of classDocs) for (const lf of c.levelFeatures ?? []) for (const fid of lf.featureIds ?? []) featureIds.add(fid); + for (const sc of subclassDocs) for (const lf of sc.levelFeatures ?? []) for (const fid of lf.featureIds ?? []) featureIds.add(fid); + for (const app of character.appliedFeatures ?? []) featureIds.add(app.featureId); + + const featureDocs = featureIds.size + ? await db.collection("features").find({ id: { $in: [...featureIds] } }).toArray() + : []; + + return NextResponse.json({ + character, + species: speciesDocs, + classes: classDocs, + subclasses: subclassDocs, + backgrounds: backgroundDocs, + features: featureDocs, + spells: spellDocs, + }); +} diff --git a/src/app/charactersheet/CharacterSheet.tsx b/src/app/charactersheet/CharacterSheet.tsx index f3ac069..5ba3b19 100644 --- a/src/app/charactersheet/CharacterSheet.tsx +++ b/src/app/charactersheet/CharacterSheet.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; +import { useSession } from "next-auth/react"; import UserIcon from "@iconify-react/fa6-solid/user"; import type { Background, @@ -54,6 +55,7 @@ export default function CharacterSheet() { const searchParams = useSearchParams(); const id = searchParams.get("id"); const { roll } = useRollHistory(); + const { data: session } = useSession(); const [character, setCharacter] = useState(null); const [species, setSpecies] = useState([]); @@ -66,26 +68,26 @@ export default function CharacterSheet() { const [loading, setLoading] = useState(true); useEffect(() => { + if (!id) { + setLoading(false); + return; + } const load = async () => { try { - const [c, sp, cl, sc, bg, fe, sl] = await Promise.all([ - fetch("/api/character").then((r) => r.json()).catch(() => ({})), - fetch("/api/species").then((r) => r.json()).catch(() => ({})), - fetch("/api/classes").then((r) => r.json()).catch(() => ({})), - fetch("/api/subclasses").then((r) => r.json()).catch(() => ({})), - fetch("/api/backgrounds").then((r) => r.json()).catch(() => ({})), - fetch("/api/features").then((r) => r.json()).catch(() => ({})), - fetch("/api/spells").then((r) => r.json()).catch(() => ({})), - ]); - const chars: Character[] = c.results ?? []; - const chosen = id ? chars.find((x) => x.id === id) : chars[0]; - setCharacter(chosen ?? null); - setSpecies(sp.results ?? []); - setClasses(cl.results ?? []); - setSubclasses(sc.results ?? []); - setBackgrounds(bg.results ?? []); - setFeatures(fe.results ?? []); - setSpells(sl.results ?? []); + const res = await fetch(`/api/character/${encodeURIComponent(id)}`); + if (!res.ok) { + const { error: err } = await res.json().catch(() => ({})); + setError(err ?? `Failed to load (${res.status})`); + return; + } + const data = await res.json(); + setCharacter(data.character ?? null); + setSpecies(data.species ?? []); + setClasses(data.classes ?? []); + setSubclasses(data.subclasses ?? []); + setBackgrounds(data.backgrounds ?? []); + setFeatures(data.features ?? []); + setSpells(data.spells ?? []); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load"); } finally { @@ -95,6 +97,13 @@ export default function CharacterSheet() { load(); }, [id]); + const isOwner = Boolean( + character && session?.user?.id && ( + (character as unknown as { user_id?: string }).user_id === session.user.id || + character.userId === session.user.id + ) + ); + const featureDict = useMemo(() => { const d: Record = {}; for (const f of features) d[f.id] = f; @@ -118,6 +127,7 @@ export default function CharacterSheet() { modifier?: number; advantage?: "adv" | "dis" | "none"; }) => { + if (!isOwner) return; await roll(opts); }; @@ -147,6 +157,11 @@ export default function CharacterSheet() { return (
+ {!isOwner && ( +
+ Read-only view. Sign in as the owner to interact. +
+ )}
- +
@@ -181,7 +196,7 @@ export default function CharacterSheet() {
- +
@@ -400,9 +415,11 @@ function PassiveScores({ resolved }: { resolved: ReturnType { if (isOwner) setSuccesses(n); }; + const setF = (n: number) => { if (isOwner) setFailures(n); }; return ( @@ -412,7 +429,7 @@ function DeathSaves() { {[1, 2, 3].map((n) => (