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 { 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<Character | null>(null);
const [species, setSpecies] = useState<Species[]>([]);
@@ -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<string, Feature> = {};
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 (
<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
character={character}
speciesName={speciesName}
@@ -167,7 +182,7 @@ export default function CharacterSheet() {
<div className="space-y-4">
<SavingThrows resolved={resolved} onRoll={doRoll} />
<PassiveScores resolved={resolved} />
<DeathSaves />
<DeathSaves isOwner={isOwner} />
</div>
<div className="space-y-4">
<SkillsBlock resolved={resolved} onRoll={doRoll} />
@@ -181,7 +196,7 @@ export default function CharacterSheet() {
</div>
<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} />
</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 [failures, setFailures] = useState(0);
const setS = (n: number) => { if (isOwner) setSuccesses(n); };
const setF = (n: number) => { if (isOwner) setFailures(n); };
return (
<Panel title="Death Saves">
@@ -412,7 +429,7 @@ function DeathSaves() {
{[1, 2, 3].map((n) => (
<button
key={n}
onClick={() => setSuccesses(successes === n ? n - 1 : n)}
onClick={() => setS(successes === n ? n - 1 : n)}
className={`w-4 h-4 rounded-full border ${
successes >= n ? "bg-emerald-400 border-emerald-400" : "border-neutral-600"
}`}
@@ -425,7 +442,7 @@ function DeathSaves() {
{[1, 2, 3].map((n) => (
<button
key={n}
onClick={() => setFailures(failures === n ? n - 1 : n)}
onClick={() => setF(failures === n ? n - 1 : n)}
className={`w-4 h-4 rounded-full border ${
failures >= n ? "bg-red-400 border-red-400" : "border-neutral-600"
}`}
@@ -582,11 +599,13 @@ function SpellsBlock({
spellDict,
resolved,
onRoll,
isOwner,
}: {
character: Character;
spellDict: Record<string, Spell>;
resolved: ReturnType<typeof resolveCharacter>;
onRoll: RollFn;
isOwner: boolean;
}) {
const spellBuckets = character.magic?.spells ?? {};
const savedSlots = character.magic?.slots ?? {};
@@ -613,6 +632,7 @@ function SpellsBlock({
const spellLevelsWithSpells = Object.keys(bySpellLevel).map(Number).sort((a, b) => a - b);
const toggleSlot = (lv: number, index: number, max: number) => {
if (!isOwner) return;
const key = String(lv);
const currentUsed = used[key] ?? 0;
// 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 CharacterSheet from "./CharacterSheet";
export default async function Page() {
const session = await auth();
if (!session) {
redirect("/login?callbackUrl=/charactersheet");
}
export default function Page() {
return (
<ManualDiceLayout>
<CharacterSheet />