Made every character sheet sharable with the link by default
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,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 />
|
||||
|
||||
Reference in New Issue
Block a user