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
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
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 . .
|
||||
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 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
-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:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: dndextended:latest
|
||||
build: .
|
||||
container_name: dndextended-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -24,33 +9,5 @@ services:
|
||||
NODE_ENV: production
|
||||
MONGODB_URI: ${MONGODB_URI:-mongodb://mongo:27017/dndextended}
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
depends_on:
|
||||
mongo:
|
||||
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
|
||||
AUTH_URL: ${AUTH_URL}
|
||||
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
|
||||
@@ -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 { 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>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
|
||||
@@ -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 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>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
+9
-2
@@ -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>
|
||||
<Navbar />
|
||||
{children}
|
||||
<RollHistoryProvider>
|
||||
<Navbar />
|
||||
{children}
|
||||
<RollHistorySidebar />
|
||||
<RollToasts />
|
||||
</RollHistoryProvider>
|
||||
</DiceProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { compare } from "bcryptjs";
|
||||
import clientPromise from "./mongodb";
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
trustHost: true,
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: {
|
||||
|
||||
+36
-17
@@ -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>;
|
||||
|
||||
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();
|
||||
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") {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user