Compare commits
9 Commits
6114d34c22
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f3cd9919b | |||
| 13aae7fff8 | |||
| b33791103b | |||
| 0ac0b88f8c | |||
| 1f53b0767f | |||
| 7144ac8fed | |||
| 749dbcfe55 | |||
| 687b0e251a | |||
| d7c87bca5c |
+7
-25
@@ -1,38 +1,20 @@
|
|||||||
## syntax=docker/dockerfile:1.7
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
# ---- deps ----
|
|
||||||
FROM node:22-slim 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
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
RUN npm ci --no-audit --no-fund
|
RUN npm ci --no-audit --no-fund
|
||||||
|
|
||||||
# ---- build ----
|
|
||||||
FROM node:22-slim AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# ---- runner ----
|
|
||||||
FROM node:22-slim AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
RUN groupadd --system --gid 1001 nodejs \
|
RUN npm run build
|
||||||
&& useradd --system --uid 1001 --gid nodejs nextjs
|
|
||||||
|
|
||||||
# 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
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["npm", "start"]
|
||||||
+3
-46
@@ -1,21 +1,6 @@
|
|||||||
## Portainer stack for dndextended.
|
|
||||||
## Env vars come from Portainer stack env or a .env file in the same dir.
|
|
||||||
##
|
|
||||||
## Required env:
|
|
||||||
## MONGODB_URI - mongodb://mongo:27017/dndextended (or external cluster)
|
|
||||||
## AUTH_SECRET - openssl rand -base64 32
|
|
||||||
## AUTH_URL - public URL (https://dnd.example.com)
|
|
||||||
## AUTH_TRUST_HOST - true (behind Traefik/nginx/Pinggy)
|
|
||||||
##
|
|
||||||
## Optional:
|
|
||||||
## MONGO_ROOT_USER, MONGO_ROOT_PASSWORD - if you want mongo auth (recommended in prod)
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build: .
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: dndextended:latest
|
|
||||||
container_name: dndextended-app
|
container_name: dndextended-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -24,33 +9,5 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
MONGODB_URI: ${MONGODB_URI:-mongodb://mongo:27017/dndextended}
|
MONGODB_URI: ${MONGODB_URI:-mongodb://mongo:27017/dndextended}
|
||||||
AUTH_SECRET: ${AUTH_SECRET}
|
AUTH_SECRET: ${AUTH_SECRET}
|
||||||
depends_on:
|
AUTH_URL: ${AUTH_URL}
|
||||||
mongo:
|
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- dnd
|
|
||||||
|
|
||||||
mongo:
|
|
||||||
image: mongo:7
|
|
||||||
container_name: dndextended-mongo
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- mongo_data:/data/db
|
|
||||||
environment:
|
|
||||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-}
|
|
||||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:-}
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 20s
|
|
||||||
networks:
|
|
||||||
- dnd
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mongo_data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
dnd:
|
|
||||||
driver: bridge
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"model": "qwen2.5-coder:7b",
|
|
||||||
"messages": [{"role": "user", "content": "What is 2+2? Use the calculator tool."}],
|
|
||||||
"tools": [{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "calculator",
|
|
||||||
"description": "Adds two numbers",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"a": {"type": "number"}, "b": {"type": "number"}},
|
|
||||||
"required": ["a", "b"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
@@ -1,51 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useDiceContext } from "@/components/DiceContext";
|
import { useDiceContext } from "@/components/DiceContext";
|
||||||
import { RollOutcome } from "@/components/DiceRoller";
|
|
||||||
import { Button } from "@/components/DiceSelector";
|
import { Button } from "@/components/DiceSelector";
|
||||||
import RollCard from "@/components/RollCard";
|
import React from "react";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
interface DisplayRoll extends RollOutcome {
|
|
||||||
id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextId = 0;
|
|
||||||
|
|
||||||
export default function ManualDiceLayout({
|
export default function ManualDiceLayout({
|
||||||
children
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const { rollerRef, onRollComplete } = useDiceContext();
|
const { rollerRef } = 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));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
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} />
|
<Button rollerRef={rollerRef} />
|
||||||
</div>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -121,8 +121,8 @@ function WizardInner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-4rem)] flex flex-col md:flex-row">
|
<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-16 md:h-[calc(100vh-4rem)]">
|
<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>
|
<h1 className="text-lg font-bold text-neutral-100 mb-4">Create Character</h1>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
import { Suspense } from "react";
|
||||||
import ManualDiceLayout from "../ManualDiceLayout";
|
import ManualDiceLayout from "../ManualDiceLayout";
|
||||||
|
import CharacterSheet from "./CharacterSheet";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
<ManualDiceLayout>
|
<ManualDiceLayout>
|
||||||
<div>
|
<Suspense fallback={<div className="p-8 text-center text-neutral-400">Loading...</div>}>
|
||||||
Character Sheet
|
<CharacterSheet />
|
||||||
</div>
|
</Suspense>
|
||||||
</ManualDiceLayout>
|
</ManualDiceLayout>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,15 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-y: scroll;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
+9
-2
@@ -2,6 +2,9 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import { DiceProvider } from "@/components/DiceContext";
|
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 Navbar from "@/components/Navbar";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -33,8 +36,12 @@ export default function RootLayout({
|
|||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<DiceProvider>
|
<DiceProvider>
|
||||||
<Navbar />
|
<RollHistoryProvider>
|
||||||
{children}
|
<Navbar />
|
||||||
|
{children}
|
||||||
|
<RollHistorySidebar />
|
||||||
|
<RollToasts />
|
||||||
|
</RollHistoryProvider>
|
||||||
</DiceProvider>
|
</DiceProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function DiceProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DiceContext.Provider value={{ rollerRef, onRollComplete }}>
|
<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">
|
<div className="fixed inset-0 z-[9999] pointer-events-none">
|
||||||
<DiceRoller ref={rollerRef} onRollComplete={broadcast} />
|
<DiceRoller ref={rollerRef} onRollComplete={broadcast} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import DiceD12Icon from '@iconify-react/mdi/dice-d12';
|
|||||||
import DiceD20Icon from '@iconify-react/mdi/dice-d20';
|
import DiceD20Icon from '@iconify-react/mdi/dice-d20';
|
||||||
import { RefObject, useState } from 'react';
|
import { RefObject, useState } from 'react';
|
||||||
import { DiceRollerHandle } from './DiceRoller';
|
import { DiceRollerHandle } from './DiceRoller';
|
||||||
import { div } from 'three/tsl';
|
import { useRollHistory } from './RollHistoryContext';
|
||||||
|
|
||||||
const diceIcons = {
|
const diceIcons = {
|
||||||
4: DiceD4Icon,
|
4: DiceD4Icon,
|
||||||
@@ -23,6 +23,7 @@ interface ButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Button({ rollerRef }: ButtonProps) {
|
export function Button({ rollerRef }: ButtonProps) {
|
||||||
|
const { roll } = useRollHistory();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const [dice, setDice] = useState<Record<number, number>>({
|
const [dice, setDice] = useState<Record<number, number>>({
|
||||||
@@ -57,6 +58,8 @@ export function Button({ rollerRef }: ButtonProps) {
|
|||||||
|
|
||||||
async function handleRoll() {
|
async function handleRoll() {
|
||||||
if (!rollerRef || !rollerRef.current) return;
|
if (!rollerRef || !rollerRef.current) return;
|
||||||
|
const totalDice = Object.values(dice).reduce((a, b) => a + b, 0);
|
||||||
|
if (totalDice === 0) return;
|
||||||
|
|
||||||
const diceToRoll = dice;
|
const diceToRoll = dice;
|
||||||
|
|
||||||
@@ -69,11 +72,11 @@ export function Button({ rollerRef }: ButtonProps) {
|
|||||||
20: 0
|
20: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
rollerRef.current.roll(diceToRoll, "Custom");
|
await roll({ pool: diceToRoll, label: "Custom" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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'>
|
<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" />
|
<DiceD20FA6 height="2.5em" className="text-bg group-hover:scale-110 transition-transform duration-300" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -32,16 +32,26 @@ export interface ResolvedCharacter {
|
|||||||
grantedSpells: Set<string>;
|
grantedSpells: Set<string>;
|
||||||
grantedFeats: Set<string>;
|
grantedFeats: Set<string>;
|
||||||
extraAttacks: number;
|
extraAttacks: number;
|
||||||
advantages: { scope: AdvantageScope; condition?: string }[];
|
advantages: { sourceName: string; sourceCategory: string; scope: AdvantageScope; condition?: string }[];
|
||||||
disadvantages: { scope: AdvantageScope; condition?: string }[];
|
disadvantages: { sourceName: string; sourceCategory: string; scope: AdvantageScope; condition?: string }[];
|
||||||
actions: { source: string; note: string }[];
|
actions: { sourceName: string; sourceCategory: string; note: string }[];
|
||||||
bonusActions: { source: string; note: string }[];
|
bonusActions: { sourceName: string; sourceCategory: string; note: string }[];
|
||||||
reactions: { source: string; note: string }[];
|
reactions: { sourceName: string; sourceCategory: string; note: string }[];
|
||||||
notes: 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 t = e.target;
|
||||||
|
const attribution = {
|
||||||
|
sourceName: featureName ?? "",
|
||||||
|
sourceCategory: sourceCategory ?? "custom",
|
||||||
|
};
|
||||||
const delta =
|
const delta =
|
||||||
e.operation === "decrease" ? -(e.value ?? 0) : e.value ?? 0;
|
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;
|
r.extraAttacks += t.count;
|
||||||
return;
|
return;
|
||||||
case "advantage":
|
case "advantage":
|
||||||
r.advantages.push({ scope: t.scope, condition: t.condition });
|
r.advantages.push({ ...attribution, scope: t.scope, condition: t.condition });
|
||||||
return;
|
return;
|
||||||
case "disadvantage":
|
case "disadvantage":
|
||||||
r.disadvantages.push({ scope: t.scope, condition: t.condition });
|
r.disadvantages.push({ ...attribution, scope: t.scope, condition: t.condition });
|
||||||
return;
|
return;
|
||||||
case "action":
|
case "action":
|
||||||
r.actions.push({ source: featureName ?? "", note: t.note });
|
r.actions.push({ ...attribution, note: t.note });
|
||||||
return;
|
return;
|
||||||
case "bonusAction":
|
case "bonusAction":
|
||||||
r.bonusActions.push({ source: featureName ?? "", note: t.note });
|
r.bonusActions.push({ ...attribution, note: t.note });
|
||||||
return;
|
return;
|
||||||
case "reaction":
|
case "reaction":
|
||||||
r.reactions.push({ source: featureName ?? "", note: t.note });
|
r.reactions.push({ ...attribution, note: t.note });
|
||||||
return;
|
return;
|
||||||
case "custom":
|
case "custom":
|
||||||
r.notes.push(t.note);
|
r.notes.push({ ...attribution, note: t.note });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,7 +226,7 @@ export function resolveCharacter(
|
|||||||
const chosen = character.featureChoices?.[applied.featureId];
|
const chosen = character.featureChoices?.[applied.featureId];
|
||||||
for (const eff of f.effects ?? []) {
|
for (const eff of f.effects ?? []) {
|
||||||
if ((eff.level ?? 1) > relevantLevel) continue;
|
if ((eff.level ?? 1) > relevantLevel) continue;
|
||||||
applyEffect(r, eff, chosen, f.name);
|
applyEffect(r, eff, chosen, f.name, applied.source);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { compare } from "bcryptjs";
|
|||||||
import clientPromise from "./mongodb";
|
import clientPromise from "./mongodb";
|
||||||
|
|
||||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
|
trustHost: true,
|
||||||
providers: [
|
providers: [
|
||||||
Credentials({
|
Credentials({
|
||||||
credentials: {
|
credentials: {
|
||||||
|
|||||||
+36
-17
@@ -1,23 +1,42 @@
|
|||||||
import { MongoClient } from "mongodb";
|
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;
|
declare global {
|
||||||
let clientPromise: Promise<MongoClient>;
|
var _mongoClientPromise: Promise<MongoClient> | undefined;
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
clientPromise = globalWithMongo._mongoClientPromise;
|
|
||||||
} else {
|
|
||||||
client = new MongoClient(uri, options);
|
|
||||||
clientPromise = client.connect();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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") {
|
||||||
|
if (!globalThis._mongoClientPromise) {
|
||||||
|
globalThis._mongoClientPromise = createClientPromise();
|
||||||
|
}
|
||||||
|
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;
|
export default clientPromise;
|
||||||
|
|||||||
Reference in New Issue
Block a user