diff --git a/README.md b/README.md index 437d508..995782d 100644 --- a/README.md +++ b/README.md @@ -7,21 +7,33 @@ A modern D&D 5.5e character builder and compendium built with Next.js and MySQL. ## Features ### Character Builder -- **Step-by-step character creation** - Guided workflow through species, class, ability scores, background, and equipment selection -- **Multi-class support** - Build characters with multiple classes and track levels independently -- **Real-time preview** - See your character sheet update live as you make choices -- **Point buy & standard array** - Multiple methods for determining ability scores -- **Automatic calculations** - HP, AC, saving throws, skills, and modifiers calculated automatically +- **Step-by-step character creation** — Guided workflow through species, class, ability scores, background, gear, spells, and details +- **Multi-class support** — Build characters with multiple classes and track levels independently +- **Real-time preview** — Live character sheet with Summary, Combat, Features, and Custom tabs +- **Point buy & standard array** — Multiple methods for determining ability scores +- **Repeatable feats** — Feats marked repeatable can fill more than one milestone slot; duplicate ASI feats combine into a shared bonus pool on the Abilities step +- **Background proficiencies** — Tools, vehicles, weapons, armor, and languages from backgrounds flow into preview and saved characters +- **Automatic calculations** — HP, AC, weapon attacks, saving throws, skills, and modifiers calculated automatically ### Compendium -- **SRD Content** - Seed the full SRD 5.2.1 compendium (classes, species, spells, equipment, and more) -- **Custom Content Creation** - Create and manage your own species, classes, subclasses, backgrounds, feats, spells, equipment, and custom abilities -- **Filtering & Search** - Find content quickly with search and category filters +- **SRD content** — Seed the full SRD 5.2.1 compendium (classes, species, spells, equipment, and more) +- **Custom content creation** — Create and manage species, classes, subclasses, backgrounds, feats, spells, equipment, and custom abilities +- **Unified editor header** — Icon picker (inline with name field), name, source, and source link on one row across all compendium editors +- **Background proficiencies editor** — Structured tools & vehicles (SRD dropdown + custom), weapon categories, armor checkboxes, and languages +- **Background granted spells** — Assign spells by overall character level (1st–20th), not spell level +- **Spell editor** — Casting time, range, and duration presets with “Other” custom values; ritual and concentration on the same row as level and school +- **Section export & clear** — Export or wipe an entire compendium tab from the gear menu +- **Filtering & search** — Find content quickly with search and category filters ### Character Management -- **Save & Load Characters** - Persist characters to the database -- **Export Options** - Download character data as JSON -- **Character Sheet View** - Full character sheet with all details +- **Save & load characters** — Persist characters to MySQL; resume editing from the builder +- **Character sheet** — Condensed sheet with skills grouped by ability, merged proficiencies, subclass features, banner/portrait, and in-sheet HP tracking +- **Export options** — Download character and compendium data as JSON + +### Import +- **SRD seed** — One-click SRD import from bundled JSON (`pnpm srd:build` regenerates seed from official markdown) +- **Web import** — Paste a URL to pull homebrew-style HTML into the compendium +- **PDF & text import** — OpenAI-powered extraction (optional `OPENAI_API_KEY`) for pasted text or uploaded PDFs ## Tech Stack @@ -112,7 +124,13 @@ mysql -h localhost -u YOUR_DB_USER -p dump_stat < mysql/schema.sql Or import the file through phpMyAdmin, Adminer, or your host’s database UI. -The seed step only inserts data; it does **not** create tables. +The seed step only inserts data; it does **not** create tables. After pulling schema updates, run: + +```bash +pnpm db:migrate +``` + +This applies incremental migrations (new columns such as background `proficiencies`, character weapon/armor proficiencies, feat `repeatable`, etc.). ### 5. Remote MySQL from your laptop @@ -166,7 +184,9 @@ Restart the dev server after adding the key. Without it, import still works for --- -## Production deployment (VPS or similar) +## Production deployment (DreamHost VPS or similar) + +**This app is designed for self-hosted Node + MySQL**, not Vercel serverless. If the repo was linked to Vercel from v0, disconnect that integration in the Vercel dashboard (or remove the Git deploy hook) and deploy on your VPS instead. These steps apply to any Linux VPS or dedicated box where you run Node and MySQL yourself (DreamHost VPS, Linode, DigitalOcean, Hetzner, AWS EC2, a home server, etc.). Adjust paths and panel names for your host. @@ -223,13 +243,19 @@ NODE_OPTIONS='--max-old-space-size=4096' pnpm build pnpm start ``` -Or with PM2: +Or with PM2 (config included in `deploy/`): ```bash -pm2 start pnpm --name dump-stat -- start +pm2 start deploy/ecosystem.config.cjs pm2 save ``` +Optional standalone build (copies minimal `node_modules` into `.next/standalone`): + +```bash +NEXT_OUTPUT=standalone pnpm build +``` + ### 4. Reverse proxy (nginx example) ```nginx @@ -267,6 +293,7 @@ curl -X POST https://yourdomain.com/api/seed | **VPS** (DreamHost, DO, Linode, …) | Node + MySQL on same box, nginx in front — steps above | | **Managed MySQL** (RDS, Aiven, …) | Point `DATABASE_URL` at the provider hostname; run Node on a VPS or PaaS | | **PaaS** (Railway, Render, Fly.io) | Deploy Next.js build; attach managed MySQL; set env vars in the dashboard | +| **Vercel** | **Not recommended** — no persistent MySQL on the same project; use DreamHost VPS + nginx instead | | **Shared PHP/cPanel** | Often **no** long-running Node — use a VPS or PaaS instead unless your plan supports Node apps | DreamHost-specific: MySQL is created under **Goodies → MySQL Databases**; remote access may require an SSH tunnel or IP allowlist as described in local dev step 5. @@ -298,11 +325,18 @@ app/ └── api/ # REST routes (seed, import, data, characters) lib/ -├── db/ # MySQL connection, schema, repository +├── db/ # MySQL connection, Drizzle schema, migrations +├── builder/ # Draft storage, ASI allocation, feat selection, equipment utils +├── compendium/ # Background proficiencies, display helpers, editor field styles ├── srd/ # SRD seed data and parsers -├── import/ # Import normalization helpers +├── import/ # Import normalization and dump-stat export format └── site-images.ts # Marketing image paths +components/ +├── compendium/ # Editor header row, proficiencies editor, dropdown-or-other fields +├── builder/ # Step nav, multi-select choices, ASI allocator +└── game-icon-picker.tsx # SVG game-icons.net picker for compendium entries + mysql/ └── schema.sql # Database DDL @@ -315,7 +349,13 @@ public/ Use the Compendium section to create custom species, classes, backgrounds, feats, spells, equipment, and abilities. Custom entries are marked with source **Custom**. -Theming lives in `app/globals.css` via CSS custom properties. +Theming lives in `app/globals.css` (Arcane default plus Parchment, Stone, Moss, and Clay). Use the gear icon in the header to switch styles; choice is stored in `localStorage`. + +### Data layer (MySQL only) + +- Browser code uses `createClient()` from `@/lib/db/client` → `/api/characters` and `/api/data/*` +- Server routes use `lib/db/*` (Drizzle + `mysql2`) +- There is **no** Supabase dependency. Run `pnpm check:mysql` to verify the repo has no stray Supabase references. ## License @@ -323,6 +363,5 @@ This project uses content from the D&D 5.5e Systems Reference Document (SRD) und ## Links -- [Continue developing on v0](https://v0.app/chat/projects/prj_Z07M3vx9HphfTfMDkIp9oqtpaHYN) - [Next.js Documentation](https://nextjs.org/docs) - [Tailwind CSS](https://tailwindcss.com) diff --git a/app/api/data/[table]/route.ts b/app/api/data/[table]/route.ts index e5012df..e119c3a 100644 --- a/app/api/data/[table]/route.ts +++ b/app/api/data/[table]/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from "next/server" import { getDatabaseConfigError, formatDatabaseError } from "@/lib/db/config" +import { getPool } from "@/lib/db/index" +import { runPendingMigrationsOnPool } from "@/lib/db/migrate" import { clearTable, countRows, @@ -55,6 +57,8 @@ export async function GET( const configError = getDatabaseConfigError() if (configError) return NextResponse.json({ error: configError }, { status: 503 }) + await runPendingMigrationsOnPool(getPool()) + const { table: raw } = await params const table = resolveTable(raw) if (!table || table === "characters") { @@ -100,6 +104,8 @@ export async function POST( const configError = getDatabaseConfigError() if (configError) return NextResponse.json({ error: configError }, { status: 503 }) + await runPendingMigrationsOnPool(getPool()) + const { table: raw } = await params const table = resolveTable(raw) if (!table || table === "characters") { @@ -133,6 +139,8 @@ export async function DELETE( const configError = getDatabaseConfigError() if (configError) return NextResponse.json({ error: configError }, { status: 503 }) + await runPendingMigrationsOnPool(getPool()) + const { table: raw } = await params const table = resolveTable(raw) if (!table || table === "characters") { diff --git a/app/api/import/pdf/route.ts b/app/api/import/pdf/route.ts index 9274b12..3254e34 100644 --- a/app/api/import/pdf/route.ts +++ b/app/api/import/pdf/route.ts @@ -68,8 +68,12 @@ const ContentSchema = z.object({ name: z.string(), description: z.string().nullable(), skill_proficiencies: z.array(z.string()).nullable(), + tool_proficiencies: z.array(z.string()).nullable().optional(), feat_granted: z.string().nullable(), - ability_bonuses: z.record(z.string(), z.number()).nullable() + ability_bonuses: z.record(z.string(), z.number()).nullable(), + feature: z.object({ name: z.string(), description: z.string() }).nullable().optional(), + grants_spells: z.boolean().optional(), + granted_spells: z.record(z.string(), z.array(z.string())).nullable().optional(), })).optional(), spells: z.array(z.object({ name: z.string(), diff --git a/app/api/import/text/route.ts b/app/api/import/text/route.ts index b8c9ce2..c0c4cd7 100644 --- a/app/api/import/text/route.ts +++ b/app/api/import/text/route.ts @@ -58,8 +58,12 @@ const ContentSchema = z.object({ name: z.string(), description: z.string().nullable(), skill_proficiencies: z.array(z.string()).nullable(), + tool_proficiencies: z.array(z.string()).nullable().optional(), feat_granted: z.string().nullable(), - ability_bonuses: z.record(z.string(), z.number()).nullable() + ability_bonuses: z.record(z.string(), z.number()).nullable(), + feature: z.object({ name: z.string(), description: z.string() }).nullable().optional(), + grants_spells: z.boolean().optional(), + granted_spells: z.record(z.string(), z.array(z.string())).nullable().optional(), })).optional(), spells: z.array(z.object({ name: z.string(), diff --git a/app/api/import/web/route.ts b/app/api/import/web/route.ts index 19dbdf9..fef26d5 100644 --- a/app/api/import/web/route.ts +++ b/app/api/import/web/route.ts @@ -1,10 +1,17 @@ import { getDatabaseConfigError } from "@/lib/db/config" -import { upsertByName } from "@/lib/db/repository" +import { listRows, upsertByName } from "@/lib/db/repository" import type { CompendiumTable } from "@/lib/db/tables" import { NextRequest, NextResponse } from "next/server" import * as cheerio from "cheerio" import { formatFeatDescription } from "@/lib/compendium/feat-description" import { IMPORT_CONTENT_TYPE_HINTS } from "@/lib/import/content-type-hints" +import { + parseBackgroundAbilityFromImportText, + parseBackgroundFeatureFromText, + parseBackgroundGrantedSpellNames, + finalizeBackgroundImportRow, +} from "@/lib/import/background-parse" +import { parseBackgroundAbilityScoresLine } from "@/lib/compendium/background-utils" async function fetchPage(url: string): Promise { const response = await fetch(url, { @@ -169,37 +176,32 @@ function parseBackground(html: string, url: string) { const description = mainContent.find("p").first().text().trim() const fullText = mainContent.text() - // Extract skill proficiencies const skillsMatch = fullText.match(/Skill\s*Proficiencies?[:\s]*([^.]+)/i) - const skills = skillsMatch - ? skillsMatch[1].split(/[,&]/).map(s => s.trim()).filter(Boolean) + const skills = skillsMatch + ? skillsMatch[1].replace(/^Choose \d+:\s*/i, "").split(/\s+and\s+|[,&]/).map((s) => s.trim()).filter(Boolean) : [] - - // Extract feat + const featMatch = fullText.match(/Feat[:\s]*([^.]+)/i) - - // Extract ability bonuses (D&D 2024 format) - const abilityMatch = fullText.match(/Ability\s*Scores?[:\s]*([^.]+)/i) - const ability_bonuses: Record = {} - if (abilityMatch) { - const bonusText = abilityMatch[1].toLowerCase() - const abilities = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"] - abilities.forEach(ab => { - if (bonusText.includes(ab)) { - const match = bonusText.match(new RegExp(`${ab}[\\s:]*\\+?(\\d)`, "i")) - if (match) ability_bonuses[ab] = parseInt(match[1]) - } - }) - } + + const abilityLine = fullText.match(/Ability\s*Scores?[:\s]*([^\n.]+)/i)?.[1] + const ability_bonuses = + parseBackgroundAbilityScoresLine(abilityLine) ?? parseBackgroundAbilityFromImportText(fullText) + + const feature = parseBackgroundFeatureFromText(fullText) + const { grants_spells, spells_by_level } = parseBackgroundGrantedSpellNames(fullText) return { name, - description, + description: description || null, skill_proficiencies: skills, - feat_granted: featMatch ? featMatch[1].trim() : null, - ability_bonuses: Object.keys(ability_bonuses).length > 0 ? ability_bonuses : null, + feat_granted: featMatch ? featMatch[1].replace(/\s*\(see.*$/i, "").trim() : null, + ability_bonuses, + feature, + grants_spells, + granted_spells: null, + _granted_spell_names: grants_spells ? spells_by_level : undefined, source: new URL(url).hostname, - creator_url: url + creator_url: url, } } @@ -404,7 +406,13 @@ export async function POST(request: NextRequest) { const { result, tableName } = parsed - await upsertByName(tableName, [result]) + let row = result + if (tableName === "backgrounds") { + const spells = (await listRows("spells")) as { id: string; name: string }[] + row = finalizeBackgroundImportRow(row, spells) + } + + await upsertByName(tableName, [row]) return NextResponse.json({ success: true, diff --git a/app/builder/page.tsx b/app/builder/page.tsx index 2b71ab8..a5ff598 100644 --- a/app/builder/page.tsx +++ b/app/builder/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useRef } from "react" import { motion, AnimatePresence } from "framer-motion" import { MainNav } from "@/components/main-nav" -import { createClient } from "@/lib/supabase/client" +import { createClient } from "@/lib/db/client" import { useRouter } from "next/navigation" import { ChevronLeft, @@ -30,19 +30,37 @@ import { import { aggregateCharacteristics, applyAcCharacteristics, + applyHpCharacteristics, computeInitiative, normalizeCharacteristics, resolveUsesConfig, + sumAttackRollModifiers, + sumDamageRollModifiers, ABILITY_SCORE_KEYS, } from "@/lib/compendium/characteristic-modifiers" +import { + findBackgroundGrantedFeat, + formatBackgroundAbilityBonuses, + formatBackgroundEquipment, + formatBackgroundGrantedSpells, + getBackgroundProficiencySections, +} from "@/lib/compendium/background-display" +import { + applyBackgroundProficienciesToDraft, + getEffectiveArmorProficiencies, + getEffectiveWeaponProficiencies, + mergeProficiencyLists, +} from "@/lib/compendium/background-proficiencies" import { calculateArmorClass, calculateWeaponAttack, + getWeaponPropertyTags, isArmorItem, isShieldItem, isWeaponItem, isWeaponProficient, } from "@/lib/compendium/combat-stats" +import { resolveSpellcastingAbilityKey } from "@/lib/compendium/spell-slots" import { BuilderStepNav } from "@/components/builder/builder-step-nav" import { MultiSelectChoices } from "@/components/builder/multi-select-choices" import { AsiAllocator } from "@/components/builder/asi-allocator" @@ -85,7 +103,14 @@ import { import { aggregateAsiBonuses, allSelectedAsiAllocationsValid, + COMBINED_MILESTONE_ASI_KEY, + countMilestoneAsiFeats, + getAsiPointsUsed, + getCombinedMilestoneAsiAllocation, isAsiFeat, + milestoneAsiPointTotal, + trimAsiAllocation, + withCombinedMilestoneAsiAllocation, } from "@/lib/builder/asi-allocation" import { MAX_PORTRAIT_FILE_BYTES, normalizePortraitUrl, normalizeBannerUrl } from "@/lib/portrait" import type { @@ -112,6 +137,10 @@ const STEPS = [ const ABILITY_NAMES = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"] as const +const STANDARD_ARRAY = [15, 14, 13, 12, 10, 8] as const + +type AbilityMethod = "pointbuy" | "standard" | "roll" | "custom" + const EMPTY_CHARACTER: CharacterDraft = { name: "", level: 1, @@ -126,6 +155,9 @@ const EMPTY_CHARACTER: CharacterDraft = { wisdom: 8, charisma: 8, skill_proficiencies: [], + tool_proficiencies: [], + weapon_proficiencies: [], + armor_proficiencies: [], languages: ["Common"], spell_ids: [], equipment_ids: [], @@ -153,6 +185,7 @@ export default function BuilderPage() { const [spells, setSpells] = useState([]) const [equipment, setEquipment] = useState([]) const [feats, setFeats] = useState([]) + const [featsLoadError, setFeatsLoadError] = useState(null) const [customAbilities, setCustomAbilities] = useState([]) const [loading, setLoading] = useState(true) @@ -160,7 +193,7 @@ export default function BuilderPage() { const [character, setCharacter] = useState(EMPTY_CHARACTER) // Ability score generation method - const [abilityMethod, setAbilityMethod] = useState<"pointbuy" | "standard" | "roll">("pointbuy") + const [abilityMethod, setAbilityMethod] = useState("pointbuy") const [pointsRemaining, setPointsRemaining] = useState(27) // Search state for each step @@ -307,8 +340,8 @@ export default function BuilderPage() { if (loading || !editIdParam || editHydratedRef.current) return const hydrateFromCharacter = async () => { - const supabase = createClient() - const { data, error } = await supabase + const db = createClient() + const { data, error } = await db .from("characters") .select("*") .eq("id", editIdParam) @@ -395,24 +428,30 @@ export default function BuilderPage() { useEffect(() => { const fetchContent = async () => { - const supabase = createClient() + const db = createClient() const [classesRes, subclassesRes, speciesRes, backgroundsRes, featsRes, spellsRes, equipmentRes, abilitiesRes] = await Promise.all([ - supabase.from("classes").select("*").order("name"), - supabase.from("subclasses").select("*").order("name"), - supabase.from("species").select("*").order("name"), - supabase.from("backgrounds").select("*").order("name"), - supabase.from("feats").select("*").order("name"), - supabase.from("spells").select("*").order("level").order("name"), - supabase.from("equipment").select("*").order("category").order("name"), - supabase.from("custom_abilities").select("*").eq("show_in_builder", true).order("name"), + db.from("classes").select("*").order("name"), + db.from("subclasses").select("*").order("name"), + db.from("species").select("*").order("name"), + db.from("backgrounds").select("*").order("name"), + db.from("feats").select("*").order("name"), + db.from("spells").select("*").order("level").order("name"), + db.from("equipment").select("*").order("category").order("name"), + db.from("custom_abilities").select("*").eq("show_in_builder", true).order("name"), ]) setClasses(classesRes.data || []) setSubclasses(subclassesRes.data || []) setSpecies(speciesRes.data || []) setBackgrounds(backgroundsRes.data || []) - setFeats(featsRes.data || []) + if (featsRes.error) { + setFeatsLoadError(featsRes.error.message) + setFeats([]) + } else { + setFeatsLoadError(null) + setFeats(featsRes.data || []) + } setSpells(spellsRes.data || []) setEquipment(equipmentRes.data || []) setCustomAbilities(abilitiesRes.data || []) @@ -448,6 +487,28 @@ export default function BuilderPage() { setCharacter({ ...character, [ability]: newScore }) } + const setCustomAbilityScore = (ability: (typeof ABILITY_NAMES)[number], raw: string) => { + if (raw === "") return + const parsed = parseInt(raw, 10) + if (!Number.isFinite(parsed)) return + setCharacter({ ...character, [ability]: Math.min(30, Math.max(1, parsed)) }) + } + + const assignStandardArrayValue = ( + ability: (typeof ABILITY_NAMES)[number], + value: number, + ) => { + setCharacter((prev) => ({ ...prev, [ability]: value })) + } + + const isStandardValueUsedElsewhere = ( + ability: (typeof ABILITY_NAMES)[number], + value: number, + ) => + ABILITY_NAMES.some( + (name) => name !== ability && character[name] === value, + ) + const applyStandardArray = () => { setCharacter({ ...character, @@ -522,6 +583,13 @@ export default function BuilderPage() { const requiredFeatSlots = requiredFeatSlotCount(totalLevel) const selectedFeatIds = (character.feat_ids ?? []).slice(0, requiredFeatSlots) const selectedFeatCount = selectedFeatIds.filter(Boolean).length + const milestoneAsiFeatCount = countMilestoneAsiFeats(selectedFeatIds, feats) + const milestoneAsiTotalPoints = milestoneAsiPointTotal(milestoneAsiFeatCount) + const milestoneAsiAllocation = getCombinedMilestoneAsiAllocation( + asiAllocationsByFeatId, + selectedFeatIds, + feats, + ) // If level drops, trim feat slots useEffect(() => { @@ -565,7 +633,31 @@ export default function BuilderPage() { selectedFeatIds, selectedFeatCount, ]) - + + useEffect(() => { + if (milestoneAsiTotalPoints <= 0) { + setAsiAllocationsByFeatId((prev) => { + if (!prev[COMBINED_MILESTONE_ASI_KEY]) return prev + const next = { ...prev } + delete next[COMBINED_MILESTONE_ASI_KEY] + return next + }) + return + } + const allocation = getCombinedMilestoneAsiAllocation( + asiAllocationsByFeatId, + selectedFeatIds, + feats, + ) + if (getAsiPointsUsed(allocation) <= milestoneAsiTotalPoints) return + setAsiAllocationsByFeatId((prev) => + withCombinedMilestoneAsiAllocation( + prev, + trimAsiAllocation(allocation, milestoneAsiTotalPoints), + ), + ) + }, [asiAllocationsByFeatId, selectedFeatIds, feats, milestoneAsiTotalPoints]) + // Get proficiency bonus based on total level const proficiencyBonus = Math.floor((totalLevel - 1) / 4) + 2 @@ -621,8 +713,10 @@ export default function BuilderPage() { const builderCharacteristicMods = [ ...normalizeCharacteristics(selectedSpecies?.characteristics, null), - ...feats - .filter((feat) => selectedFeatIds.filter(Boolean).includes(feat.id)) + ...selectedFeatIds + .filter(Boolean) + .map((featId) => feats.find((feat) => feat.id === featId)) + .filter((feat): feat is Feat => Boolean(feat)) .flatMap((feat) => normalizeCharacteristics(feat.benefits, null)), ...customAbilities.flatMap((ability) => normalizeCharacteristics(ability.characteristics, ability.uses), @@ -659,6 +753,21 @@ export default function BuilderPage() { } const effectiveSkillProficiencies = mergedSkillProficiencies + const effectiveSkillExpertise = [...aggregatedCharacteristics.skillExpertise] + const effectiveToolProficiencies = mergeProficiencyLists( + character.tool_proficiencies, + aggregatedCharacteristics.toolProficiencies, + ) + const effectiveWeaponProficiencies = getEffectiveWeaponProficiencies( + primaryClass?.weapon_proficiencies, + character.weapon_proficiencies, + aggregatedCharacteristics.weaponProficiencies, + ) + const effectiveArmorProficiencies = getEffectiveArmorProficiencies( + primaryClass?.armor_proficiencies, + character.armor_proficiencies, + aggregatedCharacteristics.armorProficiencies, + ) const savingThrowProficiencies = [ ...new Set([ ...(primaryClass?.saving_throws || []), @@ -692,7 +801,15 @@ export default function BuilderPage() { const total = Number.isFinite(hp) ? hp : 8 + conMod return Math.max(total, 1) } - const maxHp = calculateMaxHp() + const totalCharacterLevel = + characterClasses.length > 0 + ? characterClasses.reduce((sum, cls) => sum + cls.level, 0) + : character.level + const maxHp = applyHpCharacteristics( + calculateMaxHp(), + aggregatedCharacteristics, + totalCharacterLevel, + ) const armorOptions = equipment.filter(isArmorItem) const shieldOptions = equipment.filter(isShieldItem) @@ -707,17 +824,39 @@ export default function BuilderPage() { abilityMods, proficiencyBonus, ) - const equippedWeaponAttack = + const baseEquippedWeaponAttack = equippedWeapon && primaryClass ? calculateWeaponAttack( equippedWeapon, abilityMods, proficiencyBonus, - isWeaponProficient(equippedWeapon, primaryClass.weapon_proficiencies), + isWeaponProficient(equippedWeapon, effectiveWeaponProficiencies), ) : equippedWeapon ? calculateWeaponAttack(equippedWeapon, abilityMods, proficiencyBonus, false) : null + const equippedWeaponAttack = + baseEquippedWeaponAttack && equippedWeapon + ? (() => { + const weaponProps = getWeaponPropertyTags(equippedWeapon) + const attackBonus = + baseEquippedWeaponAttack.attackBonus + + sumAttackRollModifiers(aggregatedCharacteristics, { + subcategory: equippedWeapon.subcategory ?? "", + properties: weaponProps, + }) + const damageBonus = sumDamageRollModifiers(aggregatedCharacteristics, { + subcategory: equippedWeapon.subcategory ?? "", + properties: weaponProps, + damageType: equippedWeapon.damage_type ?? "", + }) + const damageDisplay = + damageBonus > 0 + ? `${baseEquippedWeaponAttack.damageDisplay} + ${damageBonus}` + : baseEquippedWeaponAttack.damageDisplay + return { attackBonus, damageDisplay } + })() + : baseEquippedWeaponAttack // Speed from species + characteristic modifiers const baseWalkSpeed = @@ -735,8 +874,13 @@ export default function BuilderPage() { } // Passive Perception (10 + wis mod + proficiency if proficient) - const passivePerception = 10 + abilityMods.wisdom + - (effectiveSkillProficiencies.includes("Perception") ? proficiencyBonus : 0) + const passivePerception = + 10 + + abilityMods.wisdom + + (effectiveSkillProficiencies.includes("Perception") + ? proficiencyBonus * + (effectiveSkillExpertise.includes("Perception") ? 2 : 1) + : 0) // Initiative (DEX mod + characteristic modifiers) const initiative = computeInitiative( @@ -772,7 +916,7 @@ export default function BuilderPage() { const saveCharacter = async () => { setSaving(true) try { - const supabase = createClient() + const db = createClient() const cls = classes.find((c) => c.id === character.class_id) const calculatedLevel = classLevels.length > 0 @@ -838,6 +982,8 @@ export default function BuilderPage() { character.skill_proficiencies, ), tool_proficiencies: character.tool_proficiencies ?? [], + weapon_proficiencies: character.weapon_proficiencies ?? [], + armor_proficiencies: character.armor_proficiencies ?? [], languages: character.languages ?? ["Common"], equipment_ids: character.equipment_ids ?? [], spell_ids: mergeSpellPicks(spellPicksByClassId), @@ -856,8 +1002,8 @@ export default function BuilderPage() { } const { data, error } = editingCharacterId - ? await supabase.from("characters").update(characterData).eq("id", editingCharacterId).select().single() - : await supabase.from("characters").insert([characterData]).select().single() + ? await db.from("characters").update(characterData).eq("id", editingCharacterId).select().single() + : await db.from("characters").insert([characterData]).select().single() if (error || !data?.id) { console.error("Error saving character:", error) @@ -909,8 +1055,7 @@ export default function BuilderPage() { subclassByClassId, featureChoicePicks, ) && - selectedFeatCount === requiredFeatSlots && - allSelectedAsiAllocationsValid(selectedFeatIds, asiAllocationsByFeatId, feats) + selectedFeatCount === requiredFeatSlots ) case 2: return validateOriginStepChoices( @@ -919,7 +1064,8 @@ export default function BuilderPage() { selectedSpecies, speciesTraitPicks, ) - case 3: return true + case 3: + return allSelectedAsiAllocationsValid(selectedFeatIds, asiAllocationsByFeatId, feats) case 4: return true case 5: return character.name.trim().length > 0 case 6: return character.name.trim().length > 0 @@ -1372,6 +1518,19 @@ export default function BuilderPage() { + {featsLoadError && ( +

+ Could not load feats from the database ({featsLoadError}). Run{" "} + npm run db:migrate and refresh the page. +

+ )} + {!featsLoadError && feats.length === 0 && ( +

+ No feats in your compendium yet. Seed SRD content from Settings or add General + feats in the Compendium. +

+ )} + {FEAT_MILESTONES.filter((lvl) => lvl <= totalLevel).map((lvl, slotIndex) => { const pickedId = selectedFeatIds[slotIndex] ?? null const picked = feats.find((f) => f.id === pickedId) ?? null @@ -1405,21 +1564,7 @@ export default function BuilderPage() { const previousId = next[slotIndex] if (isSelected) { next[slotIndex] = "" - if (previousId) { - setAsiAllocationsByFeatId((prev) => { - const copy = { ...prev } - delete copy[previousId] - return copy - }) - } } else { - if (previousId) { - setAsiAllocationsByFeatId((prev) => { - const copy = { ...prev } - delete copy[previousId] - return copy - }) - } next[slotIndex] = feat.id } setCharacter((prev) => ({ @@ -1434,14 +1579,17 @@ export default function BuilderPage() { }`} >

{feat.name}

- {feat.level_requirement && feat.level_requirement > 1 && ( -

Lvl {feat.level_requirement}+

- )} +
+ {feat.level_requirement && feat.level_requirement > 1 && ( + Lvl {feat.level_requirement}+ + )} + {feat.repeatable && Repeatable} +
) })} - {eligible.length === 0 && ( + {eligible.length === 0 && !featsLoadError && feats.length > 0 && (

No eligible feats for this slot.

)} {picked && ( @@ -1450,15 +1598,9 @@ export default function BuilderPage() {

)} {picked && isAsiFeat(picked) && ( - - setAsiAllocationsByFeatId((prev) => ({ - ...prev, - [picked.id]: allocation, - })) - } - /> +

+ Allocate ability increases on the Abilities step. +

)} ) @@ -1596,7 +1738,30 @@ export default function BuilderPage() { e.preventDefault() ;(e.currentTarget as HTMLDivElement).click() }} - onClick={() => setCharacter({ ...character, background_id: character.background_id === bg.id ? null : bg.id })} + onClick={() => { + const nextId = + character.background_id === bg.id ? null : bg.id + const nextBg = nextId + ? backgrounds.find((b) => b.id === nextId) + : null + setCharacter((prev) => { + let next: CharacterDraft = { + ...prev, + background_id: nextId, + } + if (nextBg) { + next = applyBackgroundProficienciesToDraft(next, nextBg) + } else { + next = { + ...next, + tool_proficiencies: [], + weapon_proficiencies: [], + armor_proficiencies: [], + } + } + return next + }) + }} className={`p-2 rounded-lg border-2 text-left transition-all cursor-pointer ${ character.background_id === bg.id ? "border-accent bg-accent/10" @@ -1631,16 +1796,20 @@ export default function BuilderPage() {

Set your character's core abilities.

{/* Method Selection */} -
- {[ - { id: "pointbuy", label: "Point Buy" }, - { id: "standard", label: "Standard Array" }, - { id: "roll", label: "Roll" }, - ].map((method) => ( +
+ {( + [ + { id: "pointbuy", label: "Point Buy" }, + { id: "standard", label: "Standard Array" }, + { id: "roll", label: "Roll", dice: true }, + { id: "custom", label: "Custom" }, + ] as const + ).map((method) => ( ))}
+ {abilityMethod === "roll" && ( +
+ +
+ )} + {abilityMethod === "pointbuy" && (
Points Remaining: {pointsRemaining}
)} + {milestoneAsiFeatCount > 0 && ( +
+ + setAsiAllocationsByFeatId((prev) => + withCombinedMilestoneAsiAllocation(prev, allocation), + ) + } + /> +
+ )} +
{ABILITY_NAMES.map((ability) => (

{ability}

-
- - - {character[ability]} - - -
-

+ + {abilityMethod === "custom" ? ( +

+ setCustomAbilityScore(ability, e.target.value)} + className="w-20 text-center text-3xl font-black text-foreground px-2 py-1 bg-background border-2 border-border rounded-lg focus:outline-none focus:border-primary" + /> +
+ ) : ( +
+ {(abilityMethod === "pointbuy") && ( + + )} + + {character[ability]} + + {abilityMethod === "pointbuy" && ( + + )} +
+ )} + + {abilityMethod === "standard" && ( +
+ {STANDARD_ARRAY.map((value) => { + const selectedHere = character[ability] === value + const usedElsewhere = isStandardValueUsedElsewhere(ability, value) + const disabled = usedElsewhere && !selectedHere + return ( + + ) + })} +
+ )} + +

{getAbilityModifier(character[ability])}

@@ -2220,7 +2468,7 @@ export default function BuilderPage() { id="builder-preview" className={`lg:col-span-2 ${mobilePanel === "steps" ? "hidden lg:block" : ""}`} > -
+
{/* Header with name, classes and hit die */}

@@ -2306,7 +2554,10 @@ export default function BuilderPage() {
{SKILLS_DATA.map((skill) => { const isProficient = effectiveSkillProficiencies.includes(skill.name) - const mod = abilityMods[skill.ability] + (isProficient ? proficiencyBonus : 0) + const hasExpertise = effectiveSkillExpertise.includes(skill.name) + const mod = + abilityMods[skill.ability] + + (isProficient ? proficiencyBonus * (hasExpertise ? 2 : 1) : 0) const abilityAbbr = skill.ability.slice(0, 3).toUpperCase() return (
@@ -2401,6 +2652,39 @@ export default function BuilderPage() {

+ + {(effectiveWeaponProficiencies.length > 0 || + effectiveArmorProficiencies.length > 0 || + effectiveToolProficiencies.length > 0 || + (character.languages?.length ?? 0) > 0) && ( +
+

Proficiencies

+ {effectiveWeaponProficiencies.length > 0 && ( +

+ Weapons: + {effectiveWeaponProficiencies.join(", ")} +

+ )} + {effectiveArmorProficiencies.length > 0 && ( +

+ Armor: + {effectiveArmorProficiencies.join(", ")} +

+ )} + {effectiveToolProficiencies.length > 0 && ( +

+ Tools: + {effectiveToolProficiencies.join(", ")} +

+ )} + {(character.languages?.length ?? 0) > 0 && ( +

+ Languages: + {(character.languages ?? []).join(", ")} +

+ )} +
+ )}
)} @@ -2430,25 +2714,33 @@ export default function BuilderPage() {
{/* Spellcasting Stats (if class has spellcasting) */} - {primaryClass?.spellcasting && ( -
-

Spellcasting ({primaryClass.spellcasting.ability})

-
-
-

Spell Save DC

-

- {8 + proficiencyBonus + abilityMods[primaryClass.spellcasting.ability.toLowerCase() as keyof typeof abilityMods]} -

-
-
-

Spell Attack

-

- +{proficiencyBonus + abilityMods[primaryClass.spellcasting.ability.toLowerCase() as keyof typeof abilityMods]} + {primaryClass?.spellcasting && + (() => { + const spellKey = resolveSpellcastingAbilityKey(primaryClass.spellcasting.ability) + if (!spellKey) return null + const spellMod = abilityMods[spellKey] + return ( +

+

+ Spellcasting ({primaryClass.spellcasting.ability})

+
+
+

Spell Save DC

+

+ {8 + proficiencyBonus + spellMod} +

+
+
+

Spell Attack

+

+ +{proficiencyBonus + spellMod} +

+
+
-
-
- )} + ) + })()} {/* Resistances */}
@@ -2528,15 +2820,29 @@ export default function BuilderPage() {

Weapon Proficiencies

- {primaryClass?.weapon_proficiencies?.join(", ") || "None"} + {effectiveWeaponProficiencies.join(", ") || "None"}

Armor Proficiencies

- {primaryClass?.armor_proficiencies?.join(", ") || "None"} + {effectiveArmorProficiencies.join(", ") || "None"}

+ {(effectiveToolProficiencies.length > 0 || + (character.languages?.length ?? 0) > 0) && ( +
+

+ Tools & Languages +

+

+ {[ + ...effectiveToolProficiencies, + ...(character.languages ?? []), + ].join(", ") || "None"} +

+
+ )}
)} @@ -2774,29 +3080,93 @@ export default function BuilderPage() {
)} - {detailsModal.type === "background" && ( -
-

- {(detailsModal.item as Background).description} -

- {(detailsModal.item as Background).skill_proficiencies && ( -
-

Skills

-

- {(detailsModal.item as Background).skill_proficiencies?.join(", ")} -

-
- )} - {(detailsModal.item as Background).feat_granted && ( -
-

Starting Feat

-

- {(detailsModal.item as Background).feat_granted} -

-
- )} -
- )} + {detailsModal.type === "background" && (() => { + const bg = detailsModal.item as Background + const abilityText = formatBackgroundAbilityBonuses(bg.ability_bonuses) + const equipmentText = formatBackgroundEquipment(bg) + const grantedFeat = findBackgroundGrantedFeat(bg.feat_granted, feats) + const grantedSpellLines = formatBackgroundGrantedSpells(bg, spells) + + return ( +
+ {bg.description?.trim() && ( +

{bg.description}

+ )} + + {abilityText && ( +
+

Ability Scores

+

{abilityText}

+
+ )} + + {bg.skill_proficiencies && bg.skill_proficiencies.length > 0 && ( +
+

Skills

+

{bg.skill_proficiencies.join(", ")}

+
+ )} + + {getBackgroundProficiencySections(bg).map((section) => ( +
+

{section.label}

+

{section.items.join(", ")}

+
+ ))} + + {bg.feat_granted && ( +
+

Origin Feat

+

{bg.feat_granted}

+ {grantedFeat?.description && ( +

+ {grantedFeat.description} +

+ )} +
+ )} + + {(bg.feature?.name || bg.feature?.description) && ( +
+

Background Feature

+ {bg.feature?.name && ( +

{bg.feature.name}

+ )} + {bg.feature?.description && ( +

+ {bg.feature.description} +

+ )} +
+ )} + + {grantedSpellLines.length > 0 && ( +
+

Granted Spells

+
    + {grantedSpellLines.map((line) => ( +
  • {line}
  • + ))} +
+
+ )} + + {equipmentText && ( +
+

Starting Equipment

+

{equipmentText}

+
+ )} + + {bg.starting_gold != null && bg.starting_gold > 0 && ( +
+

Starting Gold

+

{bg.starting_gold} gp

+
+ )} +
+ ) + })()} {detailsModal.type === "spell" && (
diff --git a/app/characters/[id]/character-sheet-client.tsx b/app/characters/[id]/character-sheet-client.tsx index 64d1a74..a25f303 100644 --- a/app/characters/[id]/character-sheet-client.tsx +++ b/app/characters/[id]/character-sheet-client.tsx @@ -1,9 +1,9 @@ "use client" -import { useState, useEffect, useMemo } from "react" +import { useState, useEffect, useMemo, useCallback, useRef } from "react" import { motion, AnimatePresence } from "framer-motion" import { MainNav } from "@/components/main-nav" -import { createClient } from "@/lib/supabase/client" +import { createClient } from "@/lib/db/client" import { ArrowLeft, User, @@ -17,6 +17,7 @@ import { Pencil, FileText, Plus, + Search, } from "lucide-react" import Link from "next/link" import type { @@ -33,6 +34,18 @@ import type { import { resolveUsesConfig, ABILITY_SCORE_KEYS } from "@/lib/compendium/characteristic-modifiers" import { getSkillsInAbilityOrder, ABILITY_ABBREVIATIONS } from "@/lib/compendium/skills" import { DamageRollButton } from "@/components/character-sheet/damage-roll-button" +import { D20RollButton } from "@/components/character-sheet/d20-roll-button" +import { SpellSlotTracker, consumeSpellSlot } from "@/components/character-sheet/spell-slot-tracker" +import { SpellDetailOverlay } from "@/components/character-sheet/spell-detail-overlay" +import { EquipmentDetailOverlay } from "@/components/character-sheet/equipment-detail-overlay" +import { filterEquipmentList } from "@/lib/compendium/equipment-display" +import { + getSpellSlotTable, + isConcentrationCondition, + getActiveConcentration, + formatSpellListGroupLabel, + resolveSpellcastingAbilityKey, +} from "@/lib/compendium/spell-slots" import { aggregateAsiBonuses } from "@/lib/builder/asi-allocation" import { normalizeFeatCategory } from "@/lib/builder/feat-selection" import { @@ -44,6 +57,12 @@ import { isWeaponItem, isWeaponProficient, } from "@/lib/compendium/combat-stats" +import { + getEffectiveArmorProficiencies, + getEffectiveWeaponProficiencies, +} from "@/lib/compendium/background-proficiencies" +import { SRD_CONDITIONS, getConditionDescription } from "@/lib/srd/condition-descriptions" +import { ConditionInfoTip } from "@/components/character-sheet/condition-info-tip" interface CharacterWithRelations extends Character { classes?: DndClass @@ -52,24 +71,6 @@ interface CharacterWithRelations extends Character { subclasses?: Subclass } -const CONDITIONS = [ - { name: "Blinded", description: "A blinded creature can't see and automatically fails any ability check that requires sight." }, - { name: "Charmed", description: "A charmed creature can't attack the charmer or target the charmer with harmful abilities." }, - { name: "Deafened", description: "A deafened creature can't hear and automatically fails any ability check that requires hearing." }, - { name: "Exhaustion", description: "Exhaustion is measured in six levels with increasingly severe penalties." }, - { name: "Frightened", description: "A frightened creature has disadvantage on ability checks and attack rolls while the source of fear is in sight." }, - { name: "Grappled", description: "A grappled creature's speed becomes 0 and it can't benefit from speed bonuses." }, - { name: "Incapacitated", description: "An incapacitated creature can't take actions or reactions." }, - { name: "Invisible", description: "An invisible creature is impossible to see without magic or a special sense." }, - { name: "Paralyzed", description: "A paralyzed creature is incapacitated and automatically fails Strength and Dexterity saves." }, - { name: "Petrified", description: "A petrified creature is transformed into a solid inanimate substance." }, - { name: "Poisoned", description: "A poisoned creature has disadvantage on attack rolls and ability checks." }, - { name: "Prone", description: "A prone creature's only movement option is to crawl." }, - { name: "Restrained", description: "A restrained creature's speed becomes 0." }, - { name: "Stunned", description: "A stunned creature is incapacitated and automatically fails Strength and Dexterity saves." }, - { name: "Unconscious", description: "An unconscious creature is incapacitated and falls prone." }, -] - const ABILITY_COLORS: Record = { strength: "bg-red-500", dexterity: "bg-green-500", @@ -134,14 +135,21 @@ export default function CharacterSheetClient({ id }: { id: string }) { const [tempHp, setTempHp] = useState(0) const [activeConditions, setActiveConditions] = useState([]) const [conditionDropdownOpen, setConditionDropdownOpen] = useState(false) - const [hoveredCondition, setHoveredCondition] = useState(null) const [portraitZoomOpen, setPortraitZoomOpen] = useState(false) + const [selectedSpell, setSelectedSpell] = useState(null) + const [selectedEquipment, setSelectedEquipment] = useState(null) + const [equipmentSearchQuery, setEquipmentSearchQuery] = useState("") + const [usedSpellSlots, setUsedSpellSlots] = useState([]) + const conditionButtonRef = useRef(null) + const [conditionMenuPos, setConditionMenuPos] = useState<{ top: number; left: number } | null>( + null, + ) useEffect(() => { const fetchCharacter = async () => { - const supabase = createClient() + const db = createClient() - const { data, error } = await supabase + const { data, error } = await db .from("characters") .select(`*, classes (*), species (*), backgrounds (*), subclasses (*)`) .eq("id", id) @@ -152,16 +160,16 @@ export default function CharacterSheetClient({ id }: { id: string }) { setCurrentHp(data.hit_points || data.hit_point_max || 0) if (data.spell_ids?.length) { - const { data: spellData } = await supabase.from("spells").select("*").in("id", data.spell_ids) + const { data: spellData } = await db.from("spells").select("*").in("id", data.spell_ids) if (spellData) setSpells(spellData) } if (data.equipment_ids?.length) { - const { data: equipmentData } = await supabase.from("equipment").select("*").in("id", data.equipment_ids) + const { data: equipmentData } = await db.from("equipment").select("*").in("id", data.equipment_ids) if (equipmentData) setEquipment(equipmentData) } - const { data: abilitiesData } = await supabase + const { data: abilitiesData } = await db .from("custom_abilities") .select("*") .eq("show_in_builder", true) @@ -169,13 +177,20 @@ export default function CharacterSheetClient({ id }: { id: string }) { const featIds = (data.feat_ids ?? []).filter(Boolean) if (featIds.length) { - const { data: featData } = await supabase.from("feats").select("*").in("id", featIds) - if (featData) setCharacterFeats(featData) + const uniqueFeatIds = [...new Set(featIds)] + const { data: featData } = await db.from("feats").select("*").in("id", uniqueFeatIds) + if (featData) { + const rows = featData as Feat[] + const byId = new Map(rows.map((feat) => [feat.id, feat])) + setCharacterFeats( + featIds.map((id) => byId.get(id)).filter((feat): feat is Feat => Boolean(feat)), + ) + } } const bg = data.backgrounds as Background | undefined if (bg?.feat_granted) { - const { data: originData } = await supabase + const { data: originData } = await db .from("feats") .select("*") .eq("name", bg.feat_granted) @@ -194,6 +209,21 @@ export default function CharacterSheetClient({ id }: { id: string }) { [character?.asi_allocations], ) + const spellSlotTable = useMemo(() => { + if (!character?.classes?.spellcasting) return null + return getSpellSlotTable( + character.classes.name, + character.level, + character.classes.spellcasting, + ) + }, [character?.classes, character?.level]) + + useEffect(() => { + if (spellSlotTable) { + setUsedSpellSlots(spellSlotTable.slotsByLevel.map(() => 0)) + } + }, [spellSlotTable]) + const effectiveScores = useMemo(() => { if (!character) return null return ABILITY_SCORE_KEYS.reduce( @@ -211,6 +241,32 @@ export default function CharacterSheetClient({ id }: { id: string }) { ) } + const applyConcentration = useCallback((conditionName: string) => { + setActiveConditions((prev) => [ + ...prev.filter((c) => !isConcentrationCondition(c)), + conditionName, + ]) + }, []) + + const openConditionMenu = () => { + if (conditionButtonRef.current) { + const rect = conditionButtonRef.current.getBoundingClientRect() + setConditionMenuPos({ top: rect.bottom + 4, left: rect.left }) + } + setConditionDropdownOpen((open) => !open) + } + + useEffect(() => { + if (!conditionDropdownOpen) return + const close = () => setConditionDropdownOpen(false) + window.addEventListener("scroll", close, true) + window.addEventListener("resize", close) + return () => { + window.removeEventListener("scroll", close, true) + window.removeEventListener("resize", close) + } + }, [conditionDropdownOpen]) + if (loading) { return (
@@ -269,24 +325,52 @@ export default function CharacterSheetClient({ id }: { id: string }) { .join(" · ") const skillsInOrder = getSkillsInAbilityOrder() - const weaponProficiencies = character.classes?.weapon_proficiencies ?? [] + const weaponProficiencies = getEffectiveWeaponProficiencies( + character.classes?.weapon_proficiencies, + character.weapon_proficiencies, + ) + const armorProficiencies = getEffectiveArmorProficiencies( + character.classes?.armor_proficiencies, + character.armor_proficiencies, + ) const weapons = equipment.filter(isWeaponItem) - const spellcastingAbilityKey = ( + const nonWeaponEquipment = equipment.filter((item) => !isWeaponItem(item)) + const filteredEquipment = filterEquipmentList(nonWeaponEquipment, equipmentSearchQuery) + const spellcastingAbilityLabel = character.classes?.spellcasting?.ability ?? character.subclasses?.spellcasting?.ability - )?.toLowerCase() as keyof typeof abilityMods | undefined - const hasSpellcasting = Boolean( - character.classes?.spellcasting?.ability ?? character.subclasses?.spellcasting?.ability, - ) - const spellcastingAbility = spellcastingAbilityKey - const spellSaveDc = hasSpellcasting - ? 8 + proficiencyBonus + abilityMods[spellcastingAbility!] - : null - const spellAttackMod = hasSpellcasting - ? proficiencyBonus + abilityMods[spellcastingAbility!] - : null + const spellcastingAbilityKey = resolveSpellcastingAbilityKey(spellcastingAbilityLabel) + const hasSpellcasting = Boolean(spellcastingAbilityLabel && spellcastingAbilityKey) + const spellAbilityMod = spellcastingAbilityKey ? abilityMods[spellcastingAbilityKey] : 0 + const spellSaveDc = hasSpellcasting ? 8 + proficiencyBonus + spellAbilityMod : null + const spellAttackMod = hasSpellcasting ? proficiencyBonus + spellAbilityMod : null const formatMod = (mod: number) => (mod >= 0 ? `+${mod}` : `${mod}`) + const isPerceptionProficient = character.skill_proficiencies?.includes("Perception") ?? false + const hasPerceptionExpertise = character.skill_expertise?.includes("Perception") ?? false + const passivePerception = + 10 + + abilityMods.wisdom + + (isPerceptionProficient + ? proficiencyBonus * (hasPerceptionExpertise ? 2 : 1) + : 0) + + const spellsGroupedByLevel = (() => { + const groups = new Map() + for (const spell of spells) { + const list = groups.get(spell.level) ?? [] + list.push(spell) + groups.set(spell.level, list) + } + return [...groups.entries()] + .sort(([a], [b]) => a - b) + .map(([level, levelSpells]) => ({ + level, + label: formatSpellListGroupLabel(level), + spells: levelSpells.sort((a, b) => a.name.localeCompare(b.name)), + })) + })() + return (
@@ -312,14 +396,16 @@ export default function CharacterSheetClient({ id }: { id: string }) { {character.banner_url && ( - +
+ +
)}
-
+
- {conditionDropdownOpen && ( -
- {CONDITIONS.map((condition) => ( + {conditionDropdownOpen && conditionMenuPos && ( + <> +
setConditionDropdownOpen(false)} + /> +
+ {SRD_CONDITIONS.map((condition) => ( ))} -
+
+ )} {activeConditions.length > 0 && (
- {activeConditions.map((condName) => ( - - {condName} - - - ))} + {activeConditions.map((condName) => { + const condDescription = + getConditionDescription(condName) ?? + (isConcentrationCondition(condName) + ? "You are concentrating on a spell. Concentration ends if you take damage and fail a Constitution save, cast another concentration spell, or become incapacitated." + : undefined) + return ( + + {condName} + {condDescription ? ( + + ) : null} + + + ) + })}
)}
@@ -449,10 +562,10 @@ export default function CharacterSheetClient({ id }: { id: string }) {
{[ { id: "abilities" as const, label: "Abilities & Skills", icon: }, - { id: "details" as const, label: "Character Details", icon: }, { id: "combat" as const, label: "Combat", icon: }, { id: "features" as const, label: "Features", icon: }, { id: "custom" as const, label: "Custom", icon: }, + { id: "details" as const, label: "Character Details", icon: }, ].map((tab) => ( + ))} +
- )) + ))} +
) : ( -

No other equipment

+

No spells prepared

)}
-
- {hasSpellcasting && ( -
-

Spells

-
- {spells.length ? ( - spells.map((spell) => ( -
- {spell.name} - {spell.level === 0 ? "Cantrip" : `Lvl ${spell.level}`} -
- )) - ) : ( -

No spells prepared

- )} + + {spellSlotTable ? ( +
+

Spell Slots

+
+ ) : null} +
+ )} + +
+
+

Equipment

+ {nonWeaponEquipment.length > 0 && ( + + {filteredEquipment.length} of {nonWeaponEquipment.length} + + )} +
+ {nonWeaponEquipment.length > 0 && ( +
+ + setEquipmentSearchQuery(e.target.value)} + placeholder="Search equipment..." + className="w-full pl-8 pr-3 py-1.5 text-xs bg-muted border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/40" + />
)} +
+ {filteredEquipment.length ? ( + filteredEquipment.map((item) => ( + + )) + ) : nonWeaponEquipment.length ? ( +

No equipment matches your search

+ ) : ( +

No other equipment

+ )} +
)} @@ -850,8 +1115,8 @@ export default function CharacterSheetClient({ id }: { id: string }) {

General Feats & Epic Boons

- {characterFeats.map((feat) => ( -
+ {characterFeats.map((feat, index) => ( +

{feat.name} @@ -905,6 +1170,39 @@ export default function CharacterSheetClient({ id }: { id: string }) { + {selectedEquipment && ( + setSelectedEquipment(null)} + /> + )} + {selectedSpell && ( + setSelectedSpell(null)} + onCast={(result) => { + if (result.concentrationApplied) { + applyConcentration(result.concentrationApplied) + } + if (result.slotUsed && spellSlotTable) { + const next = consumeSpellSlot( + usedSpellSlots, + spellSlotTable.slotsByLevel, + selectedSpell.level, + ) + if (next) setUsedSpellSlots(next) + } + }} + canUseSlot={ + selectedSpell.level === 0 || + (spellSlotTable != null && + (usedSpellSlots[selectedSpell.level - 1] ?? 0) < + (spellSlotTable.slotsByLevel[selectedSpell.level - 1] ?? 0)) + } + /> + )} {portraitZoomOpen && character.portrait_url && ( ("newest") const [loadError, setLoadError] = useState(null) + const [characterToDelete, setCharacterToDelete] = useState(null) useEffect(() => { const fetchCharacters = async () => { @@ -104,15 +115,17 @@ export default function CharactersPage() { setCreatedSort("newest") } - const deleteCharacter = async (id: string) => { - if (!confirm("Are you sure you want to delete this character?")) return - + const confirmDeleteCharacter = async () => { + if (!characterToDelete) return + + const id = characterToDelete.id const db = createClient() const { error } = await db.from("characters").delete().eq("id", id) - + if (!error) { - setCharacters(characters.filter(c => c.id !== id)) + setCharacters((prev) => prev.filter((c) => c.id !== id)) } + setCharacterToDelete(null) } const formatCreated = (iso: string) => @@ -305,7 +318,7 @@ export default function CharactersPage() { onClick={(e) => { e.preventDefault() e.stopPropagation() - deleteCharacter(character.id) + setCharacterToDelete(character) }} className="absolute top-3 right-3 p-2 bg-black/60 backdrop-blur-sm rounded-lg text-white/70 hover:text-destructive opacity-0 group-hover:opacity-100 transition-all" title="Delete character" @@ -352,6 +365,37 @@ export default function CharactersPage() {

)} + + { + if (!open) setCharacterToDelete(null) + }} + > + + + Delete character? + + {characterToDelete ? ( + <> + Are you sure you want to permanently delete{" "} + {characterToDelete.name}? This + cannot be undone. + + ) : null} + + + + Cancel + + Delete + + + +
) } diff --git a/app/compendium/abilities/[id]/page.tsx b/app/compendium/abilities/[id]/page.tsx index 3bd5d93..f9a0a04 100644 --- a/app/compendium/abilities/[id]/page.tsx +++ b/app/compendium/abilities/[id]/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react" import { useRouter } from "next/navigation" import { MainNav } from "@/components/main-nav" -import { createClient } from "@/lib/supabase/client" +import { createClient } from "@/lib/db/client" import { CharacteristicModifiersEditor } from "@/components/characteristic-modifiers-editor" import { CompendiumEditorToolbar, @@ -14,8 +14,8 @@ import { normalizeCharacteristics, type CharacteristicModifier, } from "@/lib/compendium/characteristic-modifiers" -import { GameIconPicker } from "@/components/game-icon-picker" -import { SourceLinkField, normalizeCreatorUrl } from "@/components/compendium/source-link-field" +import { CompendiumEditorHeaderRow } from "@/components/compendium/editor-header-row" +import { normalizeCreatorUrl } from "@/components/compendium/source-link-field" import { attachTypeToTable } from "@/lib/db/attach-target-table" import { EQUIPMENT_ATTACH_CATEGORIES, @@ -78,8 +78,8 @@ export default function AbilityEditorPage({ params }: { params: Promise<{ id: st if (id && id !== "new") { const fetchAbility = async () => { setLoading(true) - const supabase = createClient() - const { data, error } = await supabase + const db = createClient() + const { data, error } = await db .from("custom_abilities") .select("*") .eq("id", id) @@ -131,8 +131,8 @@ export default function AbilityEditorPage({ params }: { params: Promise<{ id: st return } - const supabase = createClient() - const { data } = await supabase + const db = createClient() + const { data } = await db .from(table) .select("id, name") .order("name") @@ -146,10 +146,10 @@ export default function AbilityEditorPage({ params }: { params: Promise<{ id: st useEffect(() => { const fetchOtherAbilities = async () => { - const supabase = createClient() + const db = createClient() const [{ data: abilities }, { data: spells }] = await Promise.all([ - supabase.from("custom_abilities").select("id, name").order("name").limit(100), - supabase.from("spells").select("id, name").order("name").limit(500), + db.from("custom_abilities").select("id, name").order("name").limit(100), + db.from("spells").select("id, name").order("name").limit(500), ]) setOtherAbilities((abilities || []).filter((a) => a.id !== id)) @@ -163,7 +163,7 @@ export default function AbilityEditorPage({ params }: { params: Promise<{ id: st setSaving(true) setError(null) - const supabase = createClient() + const db = createClient() const payload = { ...form, attached_to_id: form.attached_to_id || null, @@ -172,14 +172,14 @@ export default function AbilityEditorPage({ params }: { params: Promise<{ id: st } if (id === "new") { - const { error } = await supabase.from("custom_abilities").insert([payload]) + const { error } = await db.from("custom_abilities").insert([payload]) if (error) { setError(error.message) setSaving(false) return } } else { - const { error } = await supabase.from("custom_abilities").update(payload).eq("id", id) + const { error } = await db.from("custom_abilities").update(payload).eq("id", id) if (error) { setError(error.message) setSaving(false) @@ -205,8 +205,8 @@ export default function AbilityEditorPage({ params }: { params: Promise<{ id: st const handleDelete = async () => { if (!confirm("Are you sure you want to delete this custom ability?")) return - const supabase = createClient() - await supabase.from("custom_abilities").delete().eq("id", id) + const db = createClient() + await db.from("custom_abilities").delete().eq("id", id) router.push("/compendium?tab=abilities") } @@ -249,44 +249,17 @@ export default function AbilityEditorPage({ params }: { params: Promise<{ id: st )}
-
-
- - setForm({ ...form, name: e.target.value })} - required - className="w-full px-4 py-3 bg-card border-2 border-border rounded-xl text-foreground focus:outline-none focus:border-primary" - placeholder="e.g., Extra Attack" - /> -
-
- - setForm({ ...form, source: e.target.value })} - className="w-full px-4 py-3 bg-card border-2 border-border rounded-xl text-foreground focus:outline-none focus:border-primary" - placeholder="Custom, Homebrew, etc." - /> -
-
- - setForm({ ...form, creator_url })} - /> - - {/* Icon */} - setForm({ ...form, icon })} - label="Icon" + setForm({ ...form, name })} + namePlaceholder="e.g., Extra Attack" + source={form.source} + onSourceChange={(source) => setForm({ ...form, source })} + creatorUrl={form.creator_url} + onCreatorUrlChange={(creator_url) => setForm({ ...form, creator_url })} + icon={form.icon} + onIconChange={(icon) => setForm({ ...form, icon })} />
diff --git a/app/compendium/backgrounds/[id]/page.tsx b/app/compendium/backgrounds/[id]/page.tsx index c9569dc..51dec7d 100644 --- a/app/compendium/backgrounds/[id]/page.tsx +++ b/app/compendium/backgrounds/[id]/page.tsx @@ -3,16 +3,28 @@ import { useState, useEffect } from "react" import { useRouter } from "next/navigation" import { MainNav } from "@/components/main-nav" -import { createClient } from "@/lib/supabase/client" -import { Plus, X } from "lucide-react" -import { GameIconPicker } from "@/components/game-icon-picker" +import { createClient } from "@/lib/db/client" +import { Plus, Search, X } from "lucide-react" +import { + BACKGROUND_ABILITY_KEYS, + normalizeBackgroundAbilityBonuses, + normalizeGrantedSpells, + BACKGROUND_GRANT_CHARACTER_LEVELS, + formatGrantedSpellLevelKey, +} from "@/lib/compendium/background-utils" +import { CompendiumEditorHeaderRow } from "@/components/compendium/editor-header-row" import { CompendiumEditorToolbar, COMPENDIUM_EDITOR_FORM_ID, } from "@/components/compendium/editor-toolbar" -import { SourceLinkField, normalizeCreatorUrl } from "@/components/compendium/source-link-field" +import { normalizeCreatorUrl } from "@/components/compendium/source-link-field" +import { BackgroundProficienciesEditor } from "@/components/compendium/background-proficiencies-editor" +import { + emptyBackgroundProficiencies, + normalizeBackgroundProficiencies, + type BackgroundProficiencies, +} from "@/lib/compendium/background-proficiencies" -const ABILITIES = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"] const SKILLS = [ "Acrobatics", "Animal Handling", "Arcana", "Athletics", "Deception", "History", "Insight", "Intimidation", "Investigation", "Medicine", @@ -30,13 +42,17 @@ interface BackgroundFormData { description: string ability_bonuses: Record skill_proficiencies: string[] - tool_proficiencies: string[] + proficiencies: BackgroundProficiencies feat_granted: string starting_gold: number starting_equipment: EquipmentItem[] source: string creator_url: string icon: string | null + feature_name: string + feature_description: string + grants_spells: boolean + granted_spells: Record } const defaultBackground: BackgroundFormData = { @@ -44,13 +60,17 @@ const defaultBackground: BackgroundFormData = { description: "", ability_bonuses: {}, skill_proficiencies: [], - tool_proficiencies: [], + proficiencies: emptyBackgroundProficiencies(), feat_granted: "", starting_gold: 0, starting_equipment: [], source: "Custom", creator_url: "", icon: null, + feature_name: "", + feature_description: "", + grants_spells: false, + granted_spells: {}, } export default function BackgroundEditorPage({ params }: { params: Promise<{ id: string }> }) { @@ -59,10 +79,15 @@ export default function BackgroundEditorPage({ params }: { params: Promise<{ id: const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) - const [toolInput, setToolInput] = useState("") + const [weaponOptions, setWeaponOptions] = useState< + { id: string; name: string; subcategory: string | null }[] + >([]) const [equipInput, setEquipInput] = useState("") const [equipQty, setEquipQty] = useState(1) const [originFeats, setOriginFeats] = useState<{ id: string; name: string }[]>([]) + const [allSpells, setAllSpells] = useState<{ id: string; name: string; level: number }[]>([]) + const [spellSearch, setSpellSearch] = useState("") + const [characterLevelPick, setCharacterLevelPick] = useState(1) const router = useRouter() useEffect(() => { @@ -72,8 +97,8 @@ export default function BackgroundEditorPage({ params }: { params: Promise<{ id: // Fetch origin feats for the dropdown useEffect(() => { const fetchOriginFeats = async () => { - const supabase = createClient() - const { data } = await supabase + const db = createClient() + const { data } = await db .from("feats") .select("id, name") .eq("category", "Origin") @@ -83,12 +108,34 @@ export default function BackgroundEditorPage({ params }: { params: Promise<{ id: fetchOriginFeats() }, []) + useEffect(() => { + const fetchWeapons = async () => { + const db = createClient() + const { data } = await db + .from("equipment") + .select("id, name, subcategory") + .eq("category", "Weapon") + .order("name") + setWeaponOptions(data || []) + } + fetchWeapons() + }, []) + + useEffect(() => { + const fetchSpells = async () => { + const db = createClient() + const { data } = await db.from("spells").select("id, name, level").order("level").order("name") + setAllSpells(data || []) + } + fetchSpells() + }, []) + useEffect(() => { if (id && id !== "new") { const fetchBackground = async () => { setLoading(true) - const supabase = createClient() - const { data, error } = await supabase + const db = createClient() + const { data, error } = await db .from("backgrounds") .select("*") .eq("id", id) @@ -100,15 +147,22 @@ export default function BackgroundEditorPage({ params }: { params: Promise<{ id: setForm({ name: data.name || "", description: data.description || "", - ability_bonuses: data.ability_bonuses || {}, + ability_bonuses: normalizeBackgroundAbilityBonuses(data.ability_bonuses), skill_proficiencies: data.skill_proficiencies || [], - tool_proficiencies: data.tool_proficiencies || [], + proficiencies: normalizeBackgroundProficiencies( + data.proficiencies, + data.tool_proficiencies, + ), feat_granted: data.feat_granted || "", starting_gold: data.starting_gold ?? 0, starting_equipment: data.starting_equipment || [], source: data.source || "Custom", creator_url: data.creator_url || "", icon: data.icon || null, + feature_name: data.feature?.name || "", + feature_description: data.feature?.description || "", + grants_spells: Boolean(data.grants_spells), + granted_spells: normalizeGrantedSpells(data.granted_spells), }) } setLoading(false) @@ -121,14 +175,33 @@ export default function BackgroundEditorPage({ params }: { params: Promise<{ id: e.preventDefault() setSaving(true) setError(null) - const supabase = createClient() - const payload = { ...form, creator_url: normalizeCreatorUrl(form.creator_url) } + const db = createClient() + const { feature_name, feature_description, grants_spells, granted_spells, proficiencies, ...rest } = form + const normalizedProficiencies = normalizeBackgroundProficiencies(proficiencies) + const payload = { + ...rest, + proficiencies: normalizedProficiencies, + tool_proficiencies: [ + ...normalizedProficiencies.tools, + ...normalizedProficiencies.vehicles, + ], + creator_url: normalizeCreatorUrl(form.creator_url), + feature: + feature_name.trim() || feature_description.trim() + ? { + name: feature_name.trim() || "Background Feature", + description: feature_description.trim(), + } + : null, + grants_spells, + granted_spells: grants_spells ? granted_spells : null, + } if (id === "new") { - const { error } = await supabase.from("backgrounds").insert([payload]) + const { error } = await db.from("backgrounds").insert([payload]) if (error) { setError(error.message); setSaving(false); return } } else { - const { error } = await supabase.from("backgrounds").update(payload).eq("id", id) + const { error } = await db.from("backgrounds").update(payload).eq("id", id) if (error) { setError(error.message); setSaving(false); return } } @@ -149,8 +222,8 @@ export default function BackgroundEditorPage({ params }: { params: Promise<{ id: const handleDelete = async () => { if (!confirm("Are you sure you want to delete this background?")) return - const supabase = createClient() - await supabase.from("backgrounds").delete().eq("id", id) + const db = createClient() + await db.from("backgrounds").delete().eq("id", id) router.push("/compendium?tab=backgrounds") } @@ -172,16 +245,6 @@ export default function BackgroundEditorPage({ params }: { params: Promise<{ id: })) } - const addToolProficiency = () => { - if (!toolInput.trim()) return - setForm(prev => ({ ...prev, tool_proficiencies: [...prev.tool_proficiencies, toolInput.trim()] })) - setToolInput("") - } - - const removeToolProficiency = (tool: string) => { - setForm(prev => ({ ...prev, tool_proficiencies: prev.tool_proficiencies.filter(t => t !== tool) })) - } - const addEquipmentItem = () => { if (!equipInput.trim()) return setForm(prev => ({ @@ -196,6 +259,37 @@ export default function BackgroundEditorPage({ params }: { params: Promise<{ id: setForm(prev => ({ ...prev, starting_equipment: prev.starting_equipment.filter((_, i) => i !== index) })) } + const addGrantedSpell = (spellId: string) => { + const key = String(characterLevelPick) + setForm((prev) => { + const levelSpells = prev.granted_spells[key] ?? [] + if (levelSpells.includes(spellId)) return prev + return { + ...prev, + granted_spells: { ...prev.granted_spells, [key]: [...levelSpells, spellId] }, + } + }) + } + + const removeGrantedSpell = (levelKey: string, spellId: string) => { + setForm((prev) => { + const next = { ...prev.granted_spells } + next[levelKey] = (next[levelKey] ?? []).filter((id) => id !== spellId) + if (next[levelKey].length === 0) delete next[levelKey] + return { ...prev, granted_spells: next } + }) + } + + const filteredSpellsForGrant = allSpells.filter((spell) => { + const q = spellSearch.trim().toLowerCase() + if (!q) return true + return spell.name.toLowerCase().includes(q) + }) + + const grantedSpellLevelKeys = Object.keys(form.granted_spells).sort( + (a, b) => parseInt(a, 10) - parseInt(b, 10), + ) + if (loading) { return (
@@ -231,51 +325,26 @@ export default function BackgroundEditorPage({ params }: { params: Promise<{ id: )} - {/* Basic Info */} -
-
- - setForm({ ...form, name: e.target.value })} - required - className="w-full px-4 py-3 bg-card border-2 border-border rounded-xl text-foreground focus:outline-none focus:border-primary" - placeholder="Sage" - /> -
-
- - setForm({ ...form, source: e.target.value })} - className="w-full px-4 py-3 bg-card border-2 border-border rounded-xl text-foreground focus:outline-none focus:border-primary" - placeholder="Player's Handbook" - /> -
-
- - setForm({ ...form, creator_url })} - /> - - {/* Icon */} - setForm({ ...form, icon })} - label="Icon" + setForm({ ...form, name })} + namePlaceholder="Sage" + source={form.source} + onSourceChange={(source) => setForm({ ...form, source })} + creatorUrl={form.creator_url} + onCreatorUrlChange={(creator_url) => setForm({ ...form, creator_url })} + icon={form.icon} + onIconChange={(icon) => setForm({ ...form, icon })} /> - {/* Description */}