Compare commits

...

7 Commits

Author SHA1 Message Date
Alexander Harding 6f3cd9919b Remove sign in banner 2026-07-31 23:28:55 -04:00
Alexander Harding 13aae7fff8 Fixed useSearchParams not in suspense 2026-07-31 23:26:44 -04:00
Alexander Harding b33791103b Made every character sheet sharable with the link by default 2026-07-31 23:06:38 -04:00
Alexander Harding 0ac0b88f8c Added Character Sheet and evolved Dice roller 2026-07-31 22:31:43 -04:00
Alexander Harding 1f53b0767f test 2026-07-31 21:16:48 -04:00
Alexander Harding 7144ac8fed build fixes 2026-07-31 20:56:22 -04:00
Alexander Harding 749dbcfe55 Rewrite Dockerfile 2026-07-31 20:37:45 -04:00
17 changed files with 1340 additions and 116 deletions
+7 -25
View File
@@ -1,38 +1,20 @@
## syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
# ---- deps ----
FROM node:latest AS deps
RUN apt-get update && apt-get install -y --no-install-recommends libc6 openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json* ./
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
# ---- build ----
FROM node:latest AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ---- runner ----
FROM node:latest AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs nextjs
RUN npm run build
# Standalone output copies only what's needed at runtime.
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
CMD ["npm", "start"]
+3 -4
View File
@@ -1,9 +1,6 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
image: dndextended:latest
build: .
container_name: dndextended-app
restart: unless-stopped
ports:
@@ -12,3 +9,5 @@ services:
NODE_ENV: production
MONGODB_URI: ${MONGODB_URI:-mongodb://mongo:27017/dndextended}
AUTH_SECRET: ${AUTH_SECRET}
AUTH_URL: ${AUTH_URL}
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
+11 -40
View File
@@ -1,51 +1,22 @@
"use client";
import { useDiceContext } from "@/components/DiceContext";
import { RollOutcome } from "@/components/DiceRoller";
import { Button } from "@/components/DiceSelector";
import RollCard from "@/components/RollCard";
import React, { useCallback, useEffect, useState } from "react";
interface DisplayRoll extends RollOutcome {
id: number;
}
let nextId = 0;
import React from "react";
export default function ManualDiceLayout({
children
children,
}: Readonly<{
children: React.ReactNode
children: React.ReactNode;
}>) {
const { rollerRef, onRollComplete } = useDiceContext();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [history, setHistory] = useState<RollOutcome[]>([]);
const [displayed, setDisplayed] = useState<DisplayRoll[]>([]);
useEffect(() => {
return onRollComplete((outcome) => {
setHistory((prev) => [...prev, outcome]);
setDisplayed((prev) => [...prev, { ...outcome, id: nextId++ }]);
});
}, [onRollComplete]);
const removeDisplay = useCallback((id: number) => {
setDisplayed((prev) => prev.filter((r) => r.id !== id));
}, []);
const { rollerRef } = useDiceContext();
return (
<div className="relative w-screen h-screen">
<div className="absolute left-0 bottom-0">
<>
{children}
<div className="fixed bottom-4 right-4 z-[10000] pointer-events-auto">
<Button rollerRef={rollerRef} />
</div>
<div className="absolute right-0 top-0 flex flex-col gap-2 p-4">
{displayed.map((item) => (
<RollCard
key={item.id}
label={item.label}
roll={item.roll}
onFadeOut={() => removeDisplay(item.id)}
/>
))}
</div>
</div>
)
</>
);
}
+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,
});
}
+2 -2
View File
@@ -121,8 +121,8 @@ function WizardInner() {
};
return (
<div className="min-h-[calc(100vh-4rem)] flex flex-col md:flex-row">
<aside className="w-full md:w-56 border-r border-neutral-800 bg-neutral-950/60 p-4 md:sticky md:top-16 md:h-[calc(100vh-4rem)]">
<div className="flex-1 flex flex-col md:flex-row">
<aside className="w-full md:w-56 border-r border-neutral-800 bg-neutral-950/60 p-4 md:sticky md:top-0 md:self-start md:max-h-screen md:overflow-y-auto">
<h1 className="text-lg font-bold text-neutral-100 mb-4">Create Character</h1>
<div className="mb-4">
+893
View File
@@ -0,0 +1,893 @@
"use client";
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,
Character,
ClassDef,
Feature,
Species,
Spell,
Subclass,
} from "@/types/content";
import { ABILITIES, SKILL_ABILITY, type Ability, type Skill } from "@/types/content";
import {
abilityModifier,
hasAdvantage,
hasDisadvantage,
resolveCharacter,
savingThrow,
skillBonus,
} from "@/lib/applyFeatures";
import { getCharacterLevel, getProficiencyBonus } from "@/lib/dndHelpers";
import { useRollHistory } from "@/components/RollHistoryContext";
const SKILLS = Object.keys(SKILL_ABILITY) as Skill[];
const ALIGNMENT_LABELS: Record<string, string> = {
lawfulGood: "Lawful Good",
neutralGood: "Neutral Good",
chaoticGood: "Chaotic Good",
lawfulNeutral: "Lawful Neutral",
trueNeutral: "True Neutral",
chaoticNeutral: "Chaotic Neutral",
lawfulEvil: "Lawful Evil",
neutralEvil: "Neutral Evil",
chaoticEvil: "Chaotic Evil",
};
// 2×3 grid order for saves — physical/mental columns
const SAVE_ORDER: Ability[] = [
"strength", "intelligence",
"dexterity", "wisdom",
"constitution", "charisma",
];
function fmt(m: number): string {
return m >= 0 ? `+${m}` : `${m}`;
}
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[]>([]);
const [classes, setClasses] = useState<ClassDef[]>([]);
const [subclasses, setSubclasses] = useState<Subclass[]>([]);
const [backgrounds, setBackgrounds] = useState<Background[]>([]);
const [features, setFeatures] = useState<Feature[]>([]);
const [spells, setSpells] = useState<Spell[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) {
setLoading(false);
return;
}
const load = async () => {
try {
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 {
setLoading(false);
}
};
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;
return d;
}, [features]);
const spellDict = useMemo(() => {
const d: Record<string, Spell> = {};
for (const s of spells) d[s.id] = s;
return d;
}, [spells]);
const resolved = useMemo(() => {
if (!character) return null;
return resolveCharacter(character, featureDict);
}, [character, featureDict]);
const doRoll = async (opts: {
pool: Record<number, number>;
label: string;
modifier?: number;
advantage?: "adv" | "dis" | "none";
}) => {
if (!isOwner) return;
await roll(opts);
};
if (loading) {
return <div className="p-8 text-center text-neutral-400">Loading character...</div>;
}
if (error) {
return <div className="p-8 text-center text-red-400">{error}</div>;
}
if (!character || !resolved) {
return (
<div className="p-8 text-center text-neutral-400">
Character not found.{" "}
<Link href="/characters" className="text-gold underline">Back to characters</Link>
</div>
);
}
const speciesName = species.find((s) => s.id === character.speciesId)?.name ?? "Unknown Species";
const cls = classes.find((c) => c.id === character.classes[0]?.classId);
const subclass = character.classes[0]?.subclassId
? subclasses.find((s) => s.id === character.classes[0]?.subclassId)
: undefined;
const background = backgrounds.find((b) => b.id === character.backgroundId);
const level = getCharacterLevel(character);
const pb = getProficiencyBonus(level);
return (
<div className="max-w-6xl mx-auto p-4 md:p-8 space-y-6">
<Header
character={character}
speciesName={speciesName}
classes={classes}
subclasses={subclasses}
backgroundName={background?.name}
level={level}
pb={pb}
resolved={resolved}
onRoll={doRoll}
/>
{/* Ability row spans full width */}
<AbilityBlock resolved={resolved} onRoll={doRoll} />
{/* 3-col: saves stack | skills | actions/side panels */}
<div className="grid grid-cols-1 lg:grid-cols-[auto_1fr_auto] gap-4 items-start">
<div className="space-y-4">
<SavingThrows resolved={resolved} onRoll={doRoll} />
<PassiveScores resolved={resolved} />
<DeathSaves isOwner={isOwner} />
</div>
<div className="space-y-4">
<SkillsBlock resolved={resolved} onRoll={doRoll} />
<ProficienciesBlock resolved={resolved} />
<DefensesBlock resolved={resolved} />
</div>
<div className="space-y-4 lg:w-72">
<ActionsBlock resolved={resolved} />
<NotesBlock resolved={resolved} />
</div>
</div>
<div className="space-y-4">
<SpellsBlock character={character} spellDict={spellDict} resolved={resolved} onRoll={doRoll} isOwner={isOwner} />
<FeaturesList character={character} featureDict={featureDict} />
</div>
</div>
);
}
function Header({
character,
speciesName,
classes,
subclasses,
backgroundName,
level,
pb,
resolved,
onRoll,
}: {
character: Character;
speciesName: string;
classes: ClassDef[];
subclasses: Subclass[];
backgroundName?: string;
level: number;
pb: number;
resolved: ReturnType<typeof resolveCharacter>;
onRoll: RollFn;
}) {
const dexMod = abilityModifier(resolved, "dexterity");
const ac = 10 + dexMod + resolved.acBonus;
const initiative = dexMod + resolved.initiativeBonus;
const hpMax = (character.hp?.max ?? 0) + resolved.hpMaxBonus;
const hpCurrent = character.hp?.current ?? hpMax;
const classRows = (character.classes ?? []).map((cc) => {
const cls = classes.find((c) => c.id === cc.classId);
const sub = cc.subclassId ? subclasses.find((s) => s.id === cc.subclassId) : undefined;
return {
classId: cc.classId,
name: cls?.name ?? "Unknown Class",
level: cc.level,
subclassName: sub?.name,
};
});
return (
<div className="p-5 bg-surface border border-gold/20 rounded-2xl flex flex-col md:flex-row gap-5">
<div className="flex-shrink-0">
<div className="w-24 h-24 md:w-32 md:h-32 rounded-2xl border-2 border-gold/40 bg-gradient-to-br from-neutral-800 to-neutral-950 flex items-center justify-center text-gold/70">
<UserIcon height="3em" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-3 flex-wrap">
<h1 className="text-3xl font-bold text-ink truncate">{character.name}</h1>
<span className="text-xl text-gold/80 font-semibold">Lv {level}</span>
</div>
<div className="mt-2 space-y-1 text-sm">
{classRows.map((row) => (
<div key={row.classId} className="text-neutral-300">
<span className="font-semibold text-ink">{row.name}</span>{" "}
<span className="text-gold">Lv {row.level}</span>
{row.subclassName && (
<span className="text-neutral-500"> · {row.subclassName}</span>
)}
</div>
))}
</div>
<div className="mt-2 text-sm text-neutral-400 flex flex-wrap gap-x-4 gap-y-1">
<div><span className="text-neutral-500">Species:</span> {speciesName}</div>
{backgroundName && (
<div><span className="text-neutral-500">Background:</span> {backgroundName}</div>
)}
{character.alignment && (
<div>
<span className="text-neutral-500">Alignment:</span>{" "}
{ALIGNMENT_LABELS[character.alignment] ?? character.alignment}
</div>
)}
</div>
<div className="mt-3 grid grid-cols-3 sm:grid-cols-5 gap-2 text-sm">
<Stat label="HP" value={`${hpCurrent}/${hpMax}`} />
<Stat label="AC" value={String(ac)} />
<button
onClick={() => onRoll({ pool: { 20: 1 }, label: "Initiative", modifier: initiative })}
className="p-2 rounded-lg border border-neutral-700 hover:border-gold/60 hover:bg-gold/5 transition text-center"
title="Roll initiative"
>
<div className="text-[10px] uppercase text-neutral-500">Init</div>
<div className="text-lg font-bold text-gold">{fmt(initiative)}</div>
</button>
<Stat label="Speed" value={`${resolved.speed}ft`} />
<Stat label="Prof" value={`+${pb}`} />
</div>
</div>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="p-2 rounded-lg border border-neutral-700 text-center">
<div className="text-[10px] uppercase text-neutral-500">{label}</div>
<div className="text-lg font-bold text-ink">{value}</div>
</div>
);
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="p-4 bg-surface border border-gold/20 rounded-2xl">
<h2 className="text-xs uppercase tracking-wider text-gold mb-3">{title}</h2>
{children}
</section>
);
}
type RollFn = (opts: {
pool: Record<number, number>;
label: string;
modifier?: number;
advantage?: "adv" | "dis" | "none";
}) => Promise<void>;
function AbilityBlock({
resolved,
onRoll,
}: {
resolved: ReturnType<typeof resolveCharacter>;
onRoll: RollFn;
}) {
return (
<Panel title="Ability Scores">
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
{ABILITIES.map((a) => {
const score = resolved.abilities[a];
const mod = abilityModifier(resolved, a);
const adv = hasAdvantage(resolved, { on: "check", ability: a });
const dis = hasDisadvantage(resolved, { on: "check", ability: a });
const advantage = adv.yes && !dis.yes ? "adv" : dis.yes && !adv.yes ? "dis" : "none";
return (
<button
key={a}
onClick={() => onRoll({ pool: { 20: 1 }, label: `${a} check`, modifier: mod, advantage })}
className="group flex flex-col items-center justify-center rounded-xl border border-neutral-700 hover:border-gold/60 hover:bg-gold/5 transition px-2 py-1.5 leading-tight"
>
<div className="text-[10px] uppercase text-neutral-500">{a.slice(0, 3)}</div>
<div className="text-xl font-bold text-gold">{fmt(mod)}</div>
<div className="text-xs text-neutral-400">{score}</div>
</button>
);
})}
</div>
</Panel>
);
}
function SavingThrows({
resolved,
onRoll,
}: {
resolved: ReturnType<typeof resolveCharacter>;
onRoll: RollFn;
}) {
return (
<Panel title="Saving Throws">
<div className="grid grid-cols-2 gap-1.5 w-max">
{SAVE_ORDER.map((a) => {
const bonus = savingThrow(resolved, a);
const prof = resolved.savingThrowProficiencies.has(a);
const adv = hasAdvantage(resolved, { on: "save", ability: a });
const dis = hasDisadvantage(resolved, { on: "save", ability: a });
const advantage = adv.yes && !dis.yes ? "adv" : dis.yes && !adv.yes ? "dis" : "none";
return (
<button
key={a}
onClick={() => onRoll({ pool: { 20: 1 }, label: `${a} save`, modifier: bonus, advantage })}
className="group flex items-center gap-2 px-2 py-1 rounded border border-neutral-700 hover:border-gold/60 hover:bg-gold/5 transition"
>
<span className={`inline-block w-1.5 h-1.5 rounded-full ${prof ? "bg-gold" : "bg-neutral-700"}`} />
<span className="text-xs capitalize text-neutral-300">{a.slice(0, 3)}</span>
<span className="font-mono text-sm text-gold ml-auto">{fmt(bonus)}</span>
{advantage === "adv" && <span className="text-[9px] text-emerald-400">adv</span>}
{advantage === "dis" && <span className="text-[9px] text-red-400">dis</span>}
</button>
);
})}
</div>
</Panel>
);
}
function PassiveScores({ resolved }: { resolved: ReturnType<typeof resolveCharacter> }) {
const rows: [string, Skill][] = [
["Perception", "perception"],
["Investigation", "investigation"],
["Insight", "insight"],
];
return (
<Panel title="Passive Scores">
<div className="space-y-1 w-max">
{rows.map(([label, skill]) => {
const bonus = skillBonus(resolved, skill);
return (
<div key={skill} className="flex items-center gap-3 text-sm">
<span className="text-neutral-400 flex-1">{label}</span>
<span className="font-mono text-gold w-8 text-right">{10 + bonus}</span>
</div>
);
})}
</div>
</Panel>
);
}
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">
<div className="space-y-2 w-max">
<div className="flex items-center gap-2">
<span className="text-xs text-emerald-400 w-16">Successes</span>
{[1, 2, 3].map((n) => (
<button
key={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"
}`}
aria-label={`Success ${n}`}
/>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-red-400 w-16">Failures</span>
{[1, 2, 3].map((n) => (
<button
key={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"
}`}
aria-label={`Failure ${n}`}
/>
))}
</div>
</div>
</Panel>
);
}
function SkillsBlock({
resolved,
onRoll,
}: {
resolved: ReturnType<typeof resolveCharacter>;
onRoll: RollFn;
}) {
return (
<Panel title="Skills">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-0.5">
{SKILLS.map((s) => {
const bonus = skillBonus(resolved, s);
const prof = resolved.skillProficiencies.has(s);
const exp = resolved.skillExpertise.has(s);
const adv = hasAdvantage(resolved, { on: "skill", skill: s });
const dis = hasDisadvantage(resolved, { on: "skill", skill: s });
const advantage = adv.yes && !dis.yes ? "adv" : dis.yes && !adv.yes ? "dis" : "none";
return (
<div
key={s}
className="flex items-center gap-2 px-1 py-0.5 text-sm"
title={SKILL_ABILITY[s]}
>
<span
className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${
exp ? "bg-purple-400" : prof ? "bg-gold" : "bg-neutral-700"
}`}
/>
<span className="text-[10px] text-neutral-500 w-8 uppercase">
{SKILL_ABILITY[s].slice(0, 3)}
</span>
<span className="capitalize truncate flex-1">{s.replace(/([A-Z])/g, " $1")}</span>
{advantage === "adv" && <span className="text-[10px] text-emerald-400">adv</span>}
{advantage === "dis" && <span className="text-[10px] text-red-400">dis</span>}
<button
onClick={() => onRoll({ pool: { 20: 1 }, label: `${s} check`, modifier: bonus, advantage })}
className="w-12 px-2 py-0.5 rounded border border-neutral-700 hover:border-gold/60 hover:bg-gold/10 transition font-mono text-gold text-center text-sm"
>
{fmt(bonus)}
</button>
</div>
);
})}
</div>
</Panel>
);
}
function ActionsBlock({ resolved }: { resolved: ReturnType<typeof resolveCharacter> }) {
const has = resolved.actions.length + resolved.bonusActions.length + resolved.reactions.length > 0;
if (!has) return null;
return (
<Panel title="Actions">
{resolved.actions.length > 0 && (
<ActionList title="Action" items={resolved.actions} accent="border-blue-500/40 text-blue-300" />
)}
{resolved.bonusActions.length > 0 && (
<ActionList title="Bonus Action" items={resolved.bonusActions} accent="border-amber-500/40 text-amber-300" />
)}
{resolved.reactions.length > 0 && (
<ActionList title="Reaction" items={resolved.reactions} accent="border-purple-500/40 text-purple-300" />
)}
</Panel>
);
}
function ActionList({
title,
items,
accent,
}: {
title: string;
items: { sourceName: string; sourceCategory: string; note: string }[];
accent: string;
}) {
return (
<div className="mb-3 last:mb-0">
<div className={`text-xs font-semibold mb-1 ${accent.split(" ")[1]}`}>{title}</div>
<div className="space-y-1">
{items.map((it, i) => (
<div key={i} className={`p-2 border rounded text-sm ${accent}`}>
<span className="text-neutral-100">{it.note}</span>
{it.sourceName && <span className="ml-2 text-xs text-neutral-500"> {it.sourceName}</span>}
</div>
))}
</div>
</div>
);
}
function ProficienciesBlock({ resolved }: { resolved: ReturnType<typeof resolveCharacter> }) {
const rows: [string, Set<string>][] = [
["Armor", resolved.armorProficiencies],
["Weapons", resolved.weaponProficiencies],
["Tools", resolved.toolProficiencies],
["Languages", resolved.languages],
];
const anything = rows.some(([, set]) => set.size > 0);
if (!anything) return null;
return (
<Panel title="Proficiencies & Languages">
<div className="space-y-1 text-sm">
{rows.map(([label, set]) =>
set.size > 0 ? (
<div key={label}>
<span className="text-neutral-500">{label}:</span>{" "}
<span className="text-neutral-100">{[...set].join(", ")}</span>
</div>
) : null
)}
</div>
</Panel>
);
}
function DefensesBlock({ resolved }: { resolved: ReturnType<typeof resolveCharacter> }) {
const rows: [string, Set<string>][] = [
["Resistances", resolved.resistances],
["Immunities", resolved.immunities],
["Vulnerabilities", resolved.vulnerabilities],
];
const anything = rows.some(([, set]) => set.size > 0);
if (!anything) return null;
return (
<Panel title="Damage Defenses">
<div className="space-y-1 text-sm">
{rows.map(([label, set]) =>
set.size > 0 ? (
<div key={label}>
<span className="text-neutral-500">{label}:</span>{" "}
<span className="text-neutral-100">{[...set].join(", ")}</span>
</div>
) : null
)}
</div>
</Panel>
);
}
function SpellsBlock({
character,
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 ?? {};
const grantedIds = [...resolved.grantedSpells];
const knownIds = new Set<string>([...Object.values(spellBuckets).flat(), ...grantedIds]);
// Local tracker for expended slots. Keyed by spell-level string. Not persisted yet.
// Default: all slots available (used = 0).
const [used, setUsed] = useState<Record<string, number>>({});
if (knownIds.size === 0 && Object.keys(savedSlots).length === 0) return null;
const bySpellLevel: Record<number, Spell[]> = {};
for (const id of knownIds) {
const s = spellDict[id];
if (!s) continue;
(bySpellLevel[s.level] ||= []).push(s);
}
const spellLevelsWithSlots = Object.keys(savedSlots)
.map(Number)
.filter((n) => (savedSlots[String(n)]?.[1] ?? 0) > 0)
.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) => {
if (!isOwner) return;
const key = String(lv);
const currentUsed = used[key] ?? 0;
// index counts from 0 to max-1. Filled = unused.
// Filled circles are (max - currentUsed) leftmost. Click a filled circle → mark as used (index >= max - currentUsed - 1 becomes used).
// Click an empty circle → mark as available.
const wasEmpty = index >= max - currentUsed;
let nextUsed;
if (wasEmpty) {
// Restore: make everything at/before index filled
nextUsed = max - (index + 1);
} else {
// Spend: consume down to this position
nextUsed = max - index;
}
setUsed({ ...used, [key]: Math.max(0, Math.min(max, nextUsed)) });
};
// Union of all levels with slots OR spells, sorted asc. Cantrips (0) always last.
const allLevels = Array.from(
new Set([...spellLevelsWithSlots, ...spellLevelsWithSpells])
).sort((a, b) => {
if (a === 0) return 1;
if (b === 0) return -1;
return a - b;
});
if (allLevels.length === 0) {
return (
<Panel title="Spells">
<p className="text-sm text-neutral-500 italic">No spells known.</p>
</Panel>
);
}
return (
<Panel title="Spells">
<div className="space-y-4">
{allLevels.map((lv) => {
const spellsAtLv = bySpellLevel[lv] ?? [];
const max = savedSlots[String(lv)]?.[1] ?? 0;
const currentUsed = used[String(lv)] ?? 0;
const available = max - currentUsed;
return (
<div key={lv}>
<div className="flex items-center justify-between mb-2 gap-3">
<div className="text-sm font-semibold text-gold">
{lv === 0 ? "Cantrips" : `Level ${lv}`}
</div>
{max > 0 && (
<div className="flex items-center gap-2">
<div className="flex gap-1">
{Array.from({ length: max }).map((_, i) => {
const filled = i < available;
return (
<button
key={i}
onClick={() => toggleSlot(lv, i, max)}
className={`w-4 h-4 rounded-full border-2 transition ${
filled
? "bg-gold border-gold hover:bg-gold-glow"
: "border-neutral-600 hover:border-gold/60"
}`}
aria-label={`Slot ${i + 1} of Level ${lv}, ${filled ? "available" : "used"}`}
/>
);
})}
</div>
<span className="text-xs text-neutral-500 font-mono">{available}/{max}</span>
</div>
)}
</div>
{spellsAtLv.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1">
{spellsAtLv.map((s) => (
<button
key={s.id}
onClick={() => onRoll({ pool: { 20: 1 }, label: `${s.name} (attack)`, modifier: 0 })}
className="text-left p-2 border border-neutral-700 rounded hover:border-gold/50 text-sm"
title={s.description}
>
<div className="font-medium">{s.name}</div>
<div className="text-xs text-neutral-500">
{s.school} · {s.castingTime}
</div>
</button>
))}
</div>
) : (
<p className="text-xs text-neutral-500 italic">No spells known at this level.</p>
)}
</div>
);
})}
</div>
</Panel>
);
}
function FeaturesList({
character,
featureDict,
}: {
character: Character;
featureDict: Record<string, Feature>;
}) {
const grouped: Record<string, Feature[]> = { species: [], class: [], background: [], feat: [], custom: [] };
const level = getCharacterLevel(character);
const classLevels: Record<string, number> = {};
for (const c of character.classes ?? []) classLevels[c.classId] = c.level;
for (const app of character.appliedFeatures ?? []) {
const f = featureDict[app.featureId];
if (!f) continue;
const relLevel = app.source === "class" ? classLevels[app.sourceId] ?? level : level;
if ((f.level ?? 1) > relLevel) continue;
const bucket = app.source in grouped ? app.source : "custom";
grouped[bucket].push(f);
}
const nonEmpty = Object.entries(grouped).filter(([, arr]) => arr.length > 0);
if (nonEmpty.length === 0) return null;
return (
<Panel title="Features & Traits">
<div className="space-y-3">
{nonEmpty.map(([bucket, arr]) => (
<div key={bucket}>
<div className="text-xs uppercase text-neutral-500 mb-2">{bucket}</div>
<div className="space-y-2">
{arr.map((f) => (
<FeatureItem key={f.id} feature={f} />
))}
</div>
</div>
))}
</div>
</Panel>
);
}
function FeatureItem({ feature }: { feature: Feature }) {
const [open, setOpen] = useState(false);
return (
<div className="border border-neutral-700 rounded overflow-hidden w-full min-w-0 max-w-full">
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-gold/5 transition text-left"
>
<span
className={`inline-block transition-transform text-gold flex-shrink-0 ${open ? "rotate-90" : ""}`}
aria-hidden
>
</span>
<span className="font-medium text-sm text-ink truncate flex-1 min-w-0">{feature.name}</span>
{feature.level != null && (
<span className="text-xs text-neutral-500 flex-shrink-0">Lv {feature.level}</span>
)}
</button>
{open && (
<div className="px-3 py-2 border-t border-neutral-800 bg-neutral-950/40 w-full min-w-0 max-w-full overflow-hidden">
{feature.description ? (
<p className="text-sm text-neutral-300 whitespace-pre-wrap [overflow-wrap:anywhere] [word-break:break-word] max-w-full">
{feature.description}
</p>
) : (
<p className="text-xs text-neutral-500 italic">No description.</p>
)}
</div>
)}
</div>
);
}
function NotesBlock({ resolved }: { resolved: ReturnType<typeof resolveCharacter> }) {
const { advantages, disadvantages, notes } = resolved;
if (advantages.length + disadvantages.length + notes.length === 0) return null;
const describeScope = (s: { on: string } & Record<string, unknown>): string => {
if (s.on === "attack") return "attack rolls";
if (s.on === "save") return `${(s as { ability?: Ability }).ability ?? "all"} saves`;
if (s.on === "skill") return `${(s as { skill?: Skill }).skill} check`;
if (s.on === "check") return `${(s as { ability?: Ability }).ability ?? "all"} checks`;
return "";
};
interface GroupedEntry {
kind: "adv" | "dis" | "note";
text: string;
condition?: string;
sourceName: string;
}
const byCategory: Record<string, Record<string, GroupedEntry[]>> = {};
const push = (e: { sourceCategory: string; sourceName: string }, item: GroupedEntry) => {
const cat = e.sourceCategory || "custom";
const feat = e.sourceName || "Untitled";
(byCategory[cat] ||= {});
(byCategory[cat][feat] ||= []).push(item);
};
for (const a of advantages) {
push(a, { kind: "adv", text: `Advantage on ${describeScope(a.scope)}`, condition: a.condition, sourceName: a.sourceName });
}
for (const d of disadvantages) {
push(d, { kind: "dis", text: `Disadvantage on ${describeScope(d.scope)}`, condition: d.condition, sourceName: d.sourceName });
}
for (const n of notes) {
push(n, { kind: "note", text: n.note, sourceName: n.sourceName });
}
const CATEGORY_ORDER = ["species", "class", "subclass", "background", "feat", "custom"];
const CATEGORY_LABELS: Record<string, string> = {
species: "Species",
class: "Class",
subclass: "Subclass",
background: "Background",
feat: "Feats",
custom: "Other",
};
const sortedCats = Object.keys(byCategory).sort(
(a, b) => (CATEGORY_ORDER.indexOf(a) - CATEGORY_ORDER.indexOf(b)) || a.localeCompare(b)
);
const kindColor = (k: GroupedEntry["kind"]) =>
k === "adv" ? "text-emerald-400" : k === "dis" ? "text-red-400" : "text-neutral-300";
return (
<Panel title="Notes & Modifiers">
<div className="space-y-3">
{sortedCats.map((cat) => {
const featMap = byCategory[cat];
const feats = Object.keys(featMap).sort();
return (
<div key={cat}>
<div className="text-[10px] uppercase text-neutral-500 tracking-wider mb-1">
{CATEGORY_LABELS[cat] ?? cat}
</div>
<div className="space-y-2">
{feats.map((featName) => (
<div key={featName}>
<div className="text-xs font-semibold text-neutral-200">{featName}</div>
<ul className="text-xs list-disc list-inside space-y-0.5 ml-1">
{featMap[featName].map((e, i) => (
<li key={i} className={kindColor(e.kind)}>
{e.text}
{e.condition && <span className="text-neutral-500"> {e.condition}</span>}
</li>
))}
</ul>
</div>
))}
</div>
</div>
);
})}
</div>
</Panel>
);
}
+6 -5
View File
@@ -1,12 +1,13 @@
"use client";
import { Suspense } from "react";
import ManualDiceLayout from "../ManualDiceLayout";
import CharacterSheet from "./CharacterSheet";
export default function Page() {
return (
<ManualDiceLayout>
<div>
Character Sheet
</div>
<Suspense fallback={<div className="p-8 text-center text-neutral-400">Loading...</div>}>
<CharacterSheet />
</Suspense>
</ManualDiceLayout>
)
);
}
+9
View File
@@ -14,6 +14,15 @@
box-sizing: border-box;
}
button:not(:disabled) {
cursor: pointer;
}
html {
overflow-y: scroll;
scrollbar-gutter: stable;
}
html,
body {
margin: 0;
+7
View File
@@ -2,6 +2,9 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { SessionProvider } from "next-auth/react";
import { DiceProvider } from "@/components/DiceContext";
import { RollHistoryProvider } from "@/components/RollHistoryContext";
import RollHistorySidebar from "@/components/RollHistorySidebar";
import RollToasts from "@/components/RollToasts";
import Navbar from "@/components/Navbar";
import "./globals.css";
@@ -33,8 +36,12 @@ export default function RootLayout({
<body className="min-h-full flex flex-col">
<SessionProvider>
<DiceProvider>
<RollHistoryProvider>
<Navbar />
{children}
<RollHistorySidebar />
<RollToasts />
</RollHistoryProvider>
</DiceProvider>
</SessionProvider>
</body>
+1 -1
View File
@@ -45,7 +45,7 @@ export function DiceProvider({ children }: { children: ReactNode }) {
return (
<DiceContext.Provider value={{ rollerRef, onRollComplete }}>
<div className="relative z-0">{children}</div>
<div className="relative z-0 flex-1 flex flex-col">{children}</div>
<div className="fixed inset-0 z-[9999] pointer-events-none">
<DiceRoller ref={rollerRef} onRollComplete={broadcast} />
</div>
+6 -3
View File
@@ -7,7 +7,7 @@ import DiceD12Icon from '@iconify-react/mdi/dice-d12';
import DiceD20Icon from '@iconify-react/mdi/dice-d20';
import { RefObject, useState } from 'react';
import { DiceRollerHandle } from './DiceRoller';
import { div } from 'three/tsl';
import { useRollHistory } from './RollHistoryContext';
const diceIcons = {
4: DiceD4Icon,
@@ -23,6 +23,7 @@ interface ButtonProps {
}
export function Button({ rollerRef }: ButtonProps) {
const { roll } = useRollHistory();
const [open, setOpen] = useState(false);
const [dice, setDice] = useState<Record<number, number>>({
@@ -57,6 +58,8 @@ export function Button({ rollerRef }: ButtonProps) {
async function handleRoll() {
if (!rollerRef || !rollerRef.current) return;
const totalDice = Object.values(dice).reduce((a, b) => a + b, 0);
if (totalDice === 0) return;
const diceToRoll = dice;
@@ -69,11 +72,11 @@ export function Button({ rollerRef }: ButtonProps) {
20: 0
});
rollerRef.current.roll(diceToRoll, "Custom");
await roll({ pool: diceToRoll, label: "Custom" });
}
return (
<div className='flex flex-row'>
<div className='flex flex-row-reverse items-end'>
<button onClick={handleOpen} className='group p-3 m-3 bg-gradient-to-br from-gold to-amber-600 hover:from-gold-glow hover:to-gold cursor-pointer rounded-full shadow-lg shadow-gold/20 hover:shadow-xl hover:shadow-gold/40 transition-all duration-300 active:scale-95'>
<DiceD20FA6 height="2.5em" className="text-bg group-hover:scale-110 transition-transform duration-300" />
</button>
+110
View File
@@ -0,0 +1,110 @@
"use client";
import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
import { useDiceContext } from "./DiceContext";
import type { RollOutcome } from "./DiceRoller";
export interface RollEntry {
id: number;
label: string;
timestamp: number;
dice: Record<number, number>; // requested pool, e.g. { 20: 1 }
raw: { sides: number; value: number }[];
sum: number; // sum of raw dice values
modifier: number;
total: number; // sum + modifier
advantage?: "none" | "adv" | "dis";
}
interface RollHistoryContextValue {
history: RollEntry[];
toasts: RollEntry[];
dismissToast: (id: number) => void;
roll: (opts: {
pool: Record<number, number>;
label: string;
modifier?: number;
advantage?: "none" | "adv" | "dis";
}) => Promise<RollEntry | null>;
clear: () => void;
open: boolean;
setOpen: (open: boolean) => void;
}
const Ctx = createContext<RollHistoryContextValue | null>(null);
export function useRollHistory() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useRollHistory must be used within RollHistoryProvider");
return ctx;
}
let nextId = 1;
export function RollHistoryProvider({ children }: { children: ReactNode }) {
const dice = useDiceContext();
const [history, setHistory] = useState<RollEntry[]>([]);
const [toasts, setToasts] = useState<RollEntry[]>([]);
const [open, setOpen] = useState(false);
const dismissToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const roll = useCallback<RollHistoryContextValue["roll"]>(
async ({ pool, label, modifier = 0, advantage = "none" }) => {
const roller = dice.rollerRef.current;
if (!roller) return null;
// For advantage/disadvantage on a single d20, roll twice and pick.
const effectivePool = { ...pool };
const isD20Only =
Object.keys(pool).length === 1 &&
pool[20] === 1 &&
advantage !== "none";
if (isD20Only) effectivePool[20] = 2;
const outcome: RollOutcome = await roller.roll(effectivePool, label);
let raw = outcome.roll.map((r) => ({ sides: r.sides, value: r.value }));
// Apply advantage/disadvantage: pick higher/lower d20, keep others.
if (isD20Only) {
const d20s = raw.filter((r) => r.sides === 20).map((r) => r.value);
const pickedValue = advantage === "adv" ? Math.max(...d20s) : Math.min(...d20s);
raw = [{ sides: 20, value: pickedValue }];
}
const sum = raw.reduce((a, r) => a + r.value, 0);
const entry: RollEntry = {
id: nextId++,
label,
timestamp: Date.now(),
dice: pool,
raw,
sum,
modifier,
total: sum + modifier,
advantage,
};
setHistory((prev) => [entry, ...prev].slice(0, 100));
setToasts((prev) => [entry, ...prev].slice(0, 4));
return entry;
},
[dice.rollerRef]
);
const clear = useCallback(() => setHistory([]), []);
return (
<Ctx.Provider value={{ history, toasts, dismissToast, roll, clear, open, setOpen }}>
{children}
</Ctx.Provider>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useRollHistory } from "./RollHistoryContext";
function titleCase(s: string): string {
return s.replace(/\b\w/g, (c) => c.toUpperCase());
}
function timeAgo(ts: number) {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 5) return "just now";
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
return `${Math.floor(s / 3600)}h ago`;
}
export default function RollHistorySidebar() {
const { history, open, setOpen } = useRollHistory();
return (
<>
{/* Toggle handle — always visible on right edge */}
<button
onClick={() => setOpen(!open)}
className="fixed right-0 top-1/2 -translate-y-1/2 z-[10001] px-2 py-4 bg-surface border border-r-0 border-gold/40 rounded-l-lg text-gold hover:bg-gold/10 transition"
aria-label="Toggle roll history"
>
<span className="text-xs writing-mode-vertical" style={{ writingMode: "vertical-rl" }}>
Rolls {history.length > 0 && `(${history.length})`}
</span>
</button>
{/* Panel */}
<aside
className={`fixed right-0 top-0 h-screen w-80 z-[10002] bg-surface border-l border-gold/30 shadow-2xl transform transition-transform ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-gold/20">
<h2 className="text-sm font-bold text-gold uppercase tracking-wide">Roll History</h2>
<button
onClick={() => setOpen(false)}
className="text-neutral-400 hover:text-neutral-100 text-2xl leading-none"
>
×
</button>
</div>
<div className="overflow-y-auto h-[calc(100vh-3.5rem)] p-3 space-y-2">
{history.length === 0 && (
<p className="text-xs text-neutral-500 italic text-center py-8">
No rolls yet. Click any ability, save, or skill on the sheet.
</p>
)}
{history.map((r) => (
<div key={r.id} className="p-3 border border-neutral-700 rounded bg-neutral-950/50">
<div className="flex items-start justify-between gap-2">
<div className="text-sm font-medium text-ink truncate">{titleCase(r.label)}</div>
<div className="text-2xl font-bold text-gold leading-none">{r.total}</div>
</div>
<div className="mt-1 text-xs text-neutral-400">
{r.raw.map((d, i) => (
<span key={i}>
<span className={d.value === d.sides ? "text-emerald-400" : d.value === 1 ? "text-red-400" : ""}>
{d.value}
</span>
<span className="text-neutral-600"> (d{d.sides})</span>
{i < r.raw.length - 1 && <span className="text-neutral-600"> + </span>}
</span>
))}
{r.modifier !== 0 && (
<>
<span className="text-neutral-600"> {r.modifier >= 0 ? "+" : ""}</span>
<span>{r.modifier}</span>
</>
)}
{r.advantage && r.advantage !== "none" && (
<span className="ml-2 text-[10px] uppercase text-amber-400">{r.advantage}</span>
)}
</div>
<div className="mt-1 text-[10px] text-neutral-500">{timeAgo(r.timestamp)}</div>
</div>
))}
</div>
</aside>
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
import { useRollHistory, type RollEntry } from "./RollHistoryContext";
function titleCase(s: string): string {
return s.replace(/\b\w/g, (c) => c.toUpperCase());
}
export default function RollToasts() {
const { toasts, dismissToast } = useRollHistory();
return (
<div className="fixed top-16 right-4 z-[10001] flex flex-col gap-2 pointer-events-none w-72">
{toasts.map((t) => (
<ToastCard key={t.id} entry={t} onDismiss={() => dismissToast(t.id)} />
))}
</div>
);
}
function ToastCard({ entry, onDismiss }: { entry: RollEntry; onDismiss: () => void }) {
const [phase, setPhase] = useState<"in" | "out">("in");
useEffect(() => {
const fadeTimer = setTimeout(() => setPhase("out"), 3500);
const removeTimer = setTimeout(onDismiss, 4200);
return () => {
clearTimeout(fadeTimer);
clearTimeout(removeTimer);
};
}, [onDismiss]);
return (
<div
className={`p-3 bg-surface border border-gold/30 rounded-xl shadow-xl pointer-events-auto ${
phase === "in"
? "animate-[cardIn_0.25s_ease-out_forwards]"
: "animate-[cardOut_0.7s_ease-in_forwards]"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="text-sm font-medium text-ink truncate">{titleCase(entry.label)}</div>
<div className="text-2xl font-bold text-gold leading-none">{entry.total}</div>
</div>
<div className="mt-1 text-xs text-neutral-400 truncate">
{entry.raw.map((d, i) => (
<span key={i}>
<span
className={
d.value === d.sides
? "text-emerald-400"
: d.value === 1 && d.sides === 20
? "text-red-400"
: ""
}
>
{d.value}
</span>
<span className="text-neutral-600">(d{d.sides})</span>
{i < entry.raw.length - 1 && <span className="text-neutral-600"> + </span>}
</span>
))}
{entry.modifier !== 0 && (
<>
<span className="text-neutral-600"> {entry.modifier >= 0 ? "+" : ""}</span>
<span>{entry.modifier}</span>
</>
)}
{entry.advantage && entry.advantage !== "none" && (
<span className="ml-2 text-[10px] uppercase text-amber-400">{entry.advantage}</span>
)}
</div>
</div>
);
}
+24 -14
View File
@@ -32,16 +32,26 @@ export interface ResolvedCharacter {
grantedSpells: Set<string>;
grantedFeats: Set<string>;
extraAttacks: number;
advantages: { scope: AdvantageScope; condition?: string }[];
disadvantages: { scope: AdvantageScope; condition?: string }[];
actions: { source: string; note: string }[];
bonusActions: { source: string; note: string }[];
reactions: { source: string; note: string }[];
notes: string[];
advantages: { sourceName: string; sourceCategory: string; scope: AdvantageScope; condition?: string }[];
disadvantages: { sourceName: string; sourceCategory: string; scope: AdvantageScope; condition?: string }[];
actions: { sourceName: string; sourceCategory: string; note: string }[];
bonusActions: { sourceName: string; sourceCategory: string; note: string }[];
reactions: { sourceName: string; sourceCategory: string; note: string }[];
notes: { sourceName: string; sourceCategory: string; note: string }[];
}
function applyEffect(r: ResolvedCharacter, e: Effect, chosen?: string[], featureName?: string) {
function applyEffect(
r: ResolvedCharacter,
e: Effect,
chosen?: string[],
featureName?: string,
sourceCategory?: string,
) {
const t = e.target;
const attribution = {
sourceName: featureName ?? "",
sourceCategory: sourceCategory ?? "custom",
};
const delta =
e.operation === "decrease" ? -(e.value ?? 0) : e.value ?? 0;
@@ -151,22 +161,22 @@ function applyEffect(r: ResolvedCharacter, e: Effect, chosen?: string[], feature
r.extraAttacks += t.count;
return;
case "advantage":
r.advantages.push({ scope: t.scope, condition: t.condition });
r.advantages.push({ ...attribution, scope: t.scope, condition: t.condition });
return;
case "disadvantage":
r.disadvantages.push({ scope: t.scope, condition: t.condition });
r.disadvantages.push({ ...attribution, scope: t.scope, condition: t.condition });
return;
case "action":
r.actions.push({ source: featureName ?? "", note: t.note });
r.actions.push({ ...attribution, note: t.note });
return;
case "bonusAction":
r.bonusActions.push({ source: featureName ?? "", note: t.note });
r.bonusActions.push({ ...attribution, note: t.note });
return;
case "reaction":
r.reactions.push({ source: featureName ?? "", note: t.note });
r.reactions.push({ ...attribution, note: t.note });
return;
case "custom":
r.notes.push(t.note);
r.notes.push({ ...attribution, note: t.note });
return;
}
}
@@ -216,7 +226,7 @@ export function resolveCharacter(
const chosen = character.featureChoices?.[applied.featureId];
for (const eff of f.effects ?? []) {
if ((eff.level ?? 1) > relevantLevel) continue;
applyEffect(r, eff, chosen, f.name);
applyEffect(r, eff, chosen, f.name, applied.source);
}
}
+1
View File
@@ -4,6 +4,7 @@ import { compare } from "bcryptjs";
import clientPromise from "./mongodb";
export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true,
providers: [
Credentials({
credentials: {
+33 -14
View File
@@ -1,23 +1,42 @@
import { MongoClient } from "mongodb";
const uri = process.env.MONGODB_URI!;
const options = {};
/**
* Lazy MongoClient promise. We don't touch `process.env.MONGODB_URI` at module
* import time so `next build` / page-data collection doesn't crash when the env
* isn't populated (e.g. inside a Docker build with no runtime env).
*/
let client: MongoClient;
let clientPromise: Promise<MongoClient>;
declare global {
var _mongoClientPromise: Promise<MongoClient> | undefined;
}
function createClientPromise(): Promise<MongoClient> {
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error("MONGODB_URI is not set");
}
const client = new MongoClient(uri, {});
return client.connect();
}
function getClientPromise(): Promise<MongoClient> {
if (process.env.NODE_ENV === "development") {
const globalWithMongo = global as typeof globalThis & {
_mongoClientPromise?: Promise<MongoClient>;
};
if (!globalWithMongo._mongoClientPromise) {
client = new MongoClient(uri, options);
globalWithMongo._mongoClientPromise = client.connect();
if (!globalThis._mongoClientPromise) {
globalThis._mongoClientPromise = createClientPromise();
}
clientPromise = globalWithMongo._mongoClientPromise;
} else {
client = new MongoClient(uri, options);
clientPromise = client.connect();
return globalThis._mongoClientPromise;
}
if (!globalThis._mongoClientPromise) {
globalThis._mongoClientPromise = createClientPromise();
}
return globalThis._mongoClientPromise;
}
const clientPromise: Promise<MongoClient> = {
then: (onFulfilled, onRejected) => getClientPromise().then(onFulfilled, onRejected),
catch: (onRejected) => getClientPromise().catch(onRejected),
finally: (onFinally) => getClientPromise().finally(onFinally),
[Symbol.toStringTag]: "Promise",
} as Promise<MongoClient>;
export default clientPromise;