Made every character sheet sharable with the link by default

This commit is contained in:
Alexander Harding
2026-07-31 23:06:38 -04:00
parent 0ac0b88f8c
commit b33791103b
3 changed files with 100 additions and 30 deletions
+56
View File
@@ -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<string>();
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,
});
}
+43 -23
View File
@@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSession } from "next-auth/react";
import UserIcon from "@iconify-react/fa6-solid/user"; import UserIcon from "@iconify-react/fa6-solid/user";
import type { import type {
Background, Background,
@@ -54,6 +55,7 @@ export default function CharacterSheet() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const id = searchParams.get("id"); const id = searchParams.get("id");
const { roll } = useRollHistory(); const { roll } = useRollHistory();
const { data: session } = useSession();
const [character, setCharacter] = useState<Character | null>(null); const [character, setCharacter] = useState<Character | null>(null);
const [species, setSpecies] = useState<Species[]>([]); const [species, setSpecies] = useState<Species[]>([]);
@@ -66,26 +68,26 @@ export default function CharacterSheet() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
if (!id) {
setLoading(false);
return;
}
const load = async () => { const load = async () => {
try { try {
const [c, sp, cl, sc, bg, fe, sl] = await Promise.all([ const res = await fetch(`/api/character/${encodeURIComponent(id)}`);
fetch("/api/character").then((r) => r.json()).catch(() => ({})), if (!res.ok) {
fetch("/api/species").then((r) => r.json()).catch(() => ({})), const { error: err } = await res.json().catch(() => ({}));
fetch("/api/classes").then((r) => r.json()).catch(() => ({})), setError(err ?? `Failed to load (${res.status})`);
fetch("/api/subclasses").then((r) => r.json()).catch(() => ({})), return;
fetch("/api/backgrounds").then((r) => r.json()).catch(() => ({})), }
fetch("/api/features").then((r) => r.json()).catch(() => ({})), const data = await res.json();
fetch("/api/spells").then((r) => r.json()).catch(() => ({})), setCharacter(data.character ?? null);
]); setSpecies(data.species ?? []);
const chars: Character[] = c.results ?? []; setClasses(data.classes ?? []);
const chosen = id ? chars.find((x) => x.id === id) : chars[0]; setSubclasses(data.subclasses ?? []);
setCharacter(chosen ?? null); setBackgrounds(data.backgrounds ?? []);
setSpecies(sp.results ?? []); setFeatures(data.features ?? []);
setClasses(cl.results ?? []); setSpells(data.spells ?? []);
setSubclasses(sc.results ?? []);
setBackgrounds(bg.results ?? []);
setFeatures(fe.results ?? []);
setSpells(sl.results ?? []);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Failed to load"); setError(e instanceof Error ? e.message : "Failed to load");
} finally { } finally {
@@ -95,6 +97,13 @@ export default function CharacterSheet() {
load(); load();
}, [id]); }, [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 featureDict = useMemo(() => {
const d: Record<string, Feature> = {}; const d: Record<string, Feature> = {};
for (const f of features) d[f.id] = f; for (const f of features) d[f.id] = f;
@@ -118,6 +127,7 @@ export default function CharacterSheet() {
modifier?: number; modifier?: number;
advantage?: "adv" | "dis" | "none"; advantage?: "adv" | "dis" | "none";
}) => { }) => {
if (!isOwner) return;
await roll(opts); await roll(opts);
}; };
@@ -147,6 +157,11 @@ export default function CharacterSheet() {
return ( return (
<div className="max-w-6xl mx-auto p-4 md:p-8 space-y-6"> <div className="max-w-6xl mx-auto p-4 md:p-8 space-y-6">
{!isOwner && (
<div className="p-3 bg-neutral-800 border border-neutral-600 rounded text-sm text-neutral-300 text-center">
Read-only view. Sign in as the owner to interact.
</div>
)}
<Header <Header
character={character} character={character}
speciesName={speciesName} speciesName={speciesName}
@@ -167,7 +182,7 @@ export default function CharacterSheet() {
<div className="space-y-4"> <div className="space-y-4">
<SavingThrows resolved={resolved} onRoll={doRoll} /> <SavingThrows resolved={resolved} onRoll={doRoll} />
<PassiveScores resolved={resolved} /> <PassiveScores resolved={resolved} />
<DeathSaves /> <DeathSaves isOwner={isOwner} />
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<SkillsBlock resolved={resolved} onRoll={doRoll} /> <SkillsBlock resolved={resolved} onRoll={doRoll} />
@@ -181,7 +196,7 @@ export default function CharacterSheet() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<SpellsBlock character={character} spellDict={spellDict} resolved={resolved} onRoll={doRoll} /> <SpellsBlock character={character} spellDict={spellDict} resolved={resolved} onRoll={doRoll} isOwner={isOwner} />
<FeaturesList character={character} featureDict={featureDict} /> <FeaturesList character={character} featureDict={featureDict} />
</div> </div>
</div> </div>
@@ -400,9 +415,11 @@ function PassiveScores({ resolved }: { resolved: ReturnType<typeof resolveCharac
); );
} }
function DeathSaves() { function DeathSaves({ isOwner }: { isOwner: boolean }) {
const [successes, setSuccesses] = useState(0); const [successes, setSuccesses] = useState(0);
const [failures, setFailures] = useState(0); const [failures, setFailures] = useState(0);
const setS = (n: number) => { if (isOwner) setSuccesses(n); };
const setF = (n: number) => { if (isOwner) setFailures(n); };
return ( return (
<Panel title="Death Saves"> <Panel title="Death Saves">
@@ -412,7 +429,7 @@ function DeathSaves() {
{[1, 2, 3].map((n) => ( {[1, 2, 3].map((n) => (
<button <button
key={n} key={n}
onClick={() => setSuccesses(successes === n ? n - 1 : n)} onClick={() => setS(successes === n ? n - 1 : n)}
className={`w-4 h-4 rounded-full border ${ className={`w-4 h-4 rounded-full border ${
successes >= n ? "bg-emerald-400 border-emerald-400" : "border-neutral-600" successes >= n ? "bg-emerald-400 border-emerald-400" : "border-neutral-600"
}`} }`}
@@ -425,7 +442,7 @@ function DeathSaves() {
{[1, 2, 3].map((n) => ( {[1, 2, 3].map((n) => (
<button <button
key={n} key={n}
onClick={() => setFailures(failures === n ? n - 1 : n)} onClick={() => setF(failures === n ? n - 1 : n)}
className={`w-4 h-4 rounded-full border ${ className={`w-4 h-4 rounded-full border ${
failures >= n ? "bg-red-400 border-red-400" : "border-neutral-600" failures >= n ? "bg-red-400 border-red-400" : "border-neutral-600"
}`} }`}
@@ -582,11 +599,13 @@ function SpellsBlock({
spellDict, spellDict,
resolved, resolved,
onRoll, onRoll,
isOwner,
}: { }: {
character: Character; character: Character;
spellDict: Record<string, Spell>; spellDict: Record<string, Spell>;
resolved: ReturnType<typeof resolveCharacter>; resolved: ReturnType<typeof resolveCharacter>;
onRoll: RollFn; onRoll: RollFn;
isOwner: boolean;
}) { }) {
const spellBuckets = character.magic?.spells ?? {}; const spellBuckets = character.magic?.spells ?? {};
const savedSlots = character.magic?.slots ?? {}; const savedSlots = character.magic?.slots ?? {};
@@ -613,6 +632,7 @@ function SpellsBlock({
const spellLevelsWithSpells = Object.keys(bySpellLevel).map(Number).sort((a, b) => a - b); const spellLevelsWithSpells = Object.keys(bySpellLevel).map(Number).sort((a, b) => a - b);
const toggleSlot = (lv: number, index: number, max: number) => { const toggleSlot = (lv: number, index: number, max: number) => {
if (!isOwner) return;
const key = String(lv); const key = String(lv);
const currentUsed = used[key] ?? 0; const currentUsed = used[key] ?? 0;
// index counts from 0 to max-1. Filled = unused. // index counts from 0 to max-1. Filled = unused.
+1 -7
View File
@@ -1,13 +1,7 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import ManualDiceLayout from "../ManualDiceLayout"; import ManualDiceLayout from "../ManualDiceLayout";
import CharacterSheet from "./CharacterSheet"; import CharacterSheet from "./CharacterSheet";
export default async function Page() { export default function Page() {
const session = await auth();
if (!session) {
redirect("/login?callbackUrl=/charactersheet");
}
return ( return (
<ManualDiceLayout> <ManualDiceLayout>
<CharacterSheet /> <CharacterSheet />