From 0fa2c646aa348eb926182c86092d7e3e5a51a3da Mon Sep 17 00:00:00 2001 From: geph Date: Thu, 4 Jun 2026 11:51:22 -0400 Subject: [PATCH 1/4] remove old vercel deployment workflow Co-authored-by: Cursor --- .github/workflows/README.md | 7 +++++++ .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 .github/workflows/README.md create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..17d6eb5 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,7 @@ +# GitHub Actions + +This repository **does not deploy to Vercel**. + +Any historical Vercel / v0 auto-deploy workflows have been removed. Production runs on a **self-hosted DreamHost VPS** (Node + MySQL + nginx). See [README.md](../../README.md#production-deployment-dreamhost-vps-or-similar). + +The only workflow here is optional CI (`ci.yml`) — lint and MySQL client checks only, no deploy step. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c520eaa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +# Self-hosted VPS app — CI only (no Vercel deploy). +name: CI + +on: + push: + branches: [main, Cursor] + pull_request: + branches: [main, Cursor] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - run: pnpm check:mysql + - run: pnpm lint From 3ac22b9693de17ccd88dda0ffd3a8e9ac1177139 Mon Sep 17 00:00:00 2001 From: geph Date: Thu, 4 Jun 2026 20:17:13 -0400 Subject: [PATCH 2/4] Add combat sheet tooling, compendium UX, and MySQL migration cleanup. Interactive rolls, spell slots, equipment details, expanded characteristic modifiers, background proficiencies, import/compendium layout improvements, app themes, and removal of legacy Supabase clients. Co-authored-by: Cursor --- README.md | 77 ++- app/api/data/[table]/route.ts | 8 + app/api/import/pdf/route.ts | 6 +- app/api/import/text/route.ts | 6 +- app/api/import/web/route.ts | 58 +- app/builder/page.tsx | 583 +++++++++++++--- .../[id]/character-sheet-client.tsx | 499 +++++++++++--- app/compendium/abilities/[id]/page.tsx | 79 +-- app/compendium/backgrounds/[id]/page.tsx | 365 +++++++--- app/compendium/classes/[id]/page.tsx | 70 +- app/compendium/equipment/[id]/page.tsx | 73 +- app/compendium/feats/[id]/page.tsx | 116 ++-- app/compendium/page.tsx | 284 ++++---- app/compendium/species/[id]/page.tsx | 73 +- app/compendium/spells/[id]/page.tsx | 276 +++++--- app/compendium/subclasses/[id]/page.tsx | 109 ++- app/globals.css | 251 ++++++- app/import/page.tsx | 633 ++++++++++-------- app/layout.tsx | 8 +- app/page.tsx | 24 +- components/builder/asi-allocator.tsx | 23 +- .../character-sheet/d20-roll-button.tsx | 62 ++ .../character-sheet/damage-roll-button.tsx | 10 +- .../equipment-detail-overlay.tsx | 75 +++ .../character-sheet/spell-detail-overlay.tsx | 241 +++++++ .../character-sheet/spell-slot-tracker.tsx | 142 ++++ .../characteristic-modifiers-editor.tsx | 392 ++++++++++- .../background-proficiencies-editor.tsx | 278 ++++++++ .../compendium/dropdown-or-other-field.tsx | 81 +++ components/compendium/editor-header-row.tsx | 83 +++ components/compendium/source-link-field.tsx | 7 +- components/game-icon-picker.tsx | 37 +- components/main-nav.tsx | 6 +- components/providers/app-theme-provider.tsx | 71 ++ components/settings/global-settings-menu.tsx | 57 ++ deploy/ecosystem.config.cjs | 21 + deploy/nginx-dump-stat.conf.example | 19 + lib/builder/asi-allocation.ts | 168 ++++- lib/builder/character-to-draft.ts | 2 + lib/builder/draft-storage.ts | 2 +- lib/builder/feat-selection.ts | 2 +- lib/compendium/background-display.ts | 90 +++ lib/compendium/background-proficiencies.ts | 140 ++++ lib/compendium/background-utils.ts | 111 +++ lib/compendium/characteristic-modifiers.ts | 397 ++++++++++- lib/compendium/combat-stats.ts | 12 +- lib/compendium/content-types.ts | 6 +- lib/compendium/editor-field-styles.ts | 7 + lib/compendium/equipment-display.ts | 87 +++ lib/compendium/spell-slots.ts | 165 +++++ lib/compendium/srd-tool-names.ts | 30 + lib/db/client.ts | 4 +- lib/db/schema-migrations.mjs | 28 + lib/db/schema.ts | 6 + lib/import/background-parse.ts | 138 ++++ lib/srd/parser.mjs | 93 ++- lib/srd/seed-data/backgrounds.json | 2 +- lib/srd/seed-data/manifest.json | 2 +- lib/srd/source.ts | 5 + lib/supabase/client.ts | 6 - lib/supabase/server.ts | 2 - lib/themes/app-themes.ts | 50 ++ lib/types.ts | 12 + mysql/schema.sql | 6 + next.config.mjs | 2 + package.json | 3 +- public/icons/bookmarklet.svg | 2 +- scripts/check-no-supabase.mjs | 49 ++ 68 files changed, 5474 insertions(+), 1358 deletions(-) create mode 100644 components/character-sheet/d20-roll-button.tsx create mode 100644 components/character-sheet/equipment-detail-overlay.tsx create mode 100644 components/character-sheet/spell-detail-overlay.tsx create mode 100644 components/character-sheet/spell-slot-tracker.tsx create mode 100644 components/compendium/background-proficiencies-editor.tsx create mode 100644 components/compendium/dropdown-or-other-field.tsx create mode 100644 components/compendium/editor-header-row.tsx create mode 100644 components/providers/app-theme-provider.tsx create mode 100644 components/settings/global-settings-menu.tsx create mode 100644 deploy/ecosystem.config.cjs create mode 100644 deploy/nginx-dump-stat.conf.example create mode 100644 lib/compendium/background-display.ts create mode 100644 lib/compendium/background-proficiencies.ts create mode 100644 lib/compendium/background-utils.ts create mode 100644 lib/compendium/editor-field-styles.ts create mode 100644 lib/compendium/equipment-display.ts create mode 100644 lib/compendium/spell-slots.ts create mode 100644 lib/compendium/srd-tool-names.ts create mode 100644 lib/import/background-parse.ts delete mode 100644 lib/supabase/client.ts delete mode 100644 lib/supabase/server.ts create mode 100644 lib/themes/app-themes.ts create mode 100644 scripts/check-no-supabase.mjs 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..663604a 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,14 +30,31 @@ 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, @@ -85,7 +102,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 +136,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 +154,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 +184,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 +192,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 +339,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 +427,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 +486,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 +582,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 +632,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 +712,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 +752,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 +800,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 +823,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 +873,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 +915,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 +981,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 +1001,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 +1054,7 @@ export default function BuilderPage() { subclassByClassId, featureChoicePicks, ) && - selectedFeatCount === requiredFeatSlots && - allSelectedAsiAllocationsValid(selectedFeatIds, asiAllocationsByFeatId, feats) + selectedFeatCount === requiredFeatSlots ) case 2: return validateOriginStepChoices( @@ -919,7 +1063,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 +1517,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 +1563,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 +1578,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 +1597,9 @@ export default function BuilderPage() {

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

+ Allocate ability increases on the Abilities step. +

)} ) @@ -1596,7 +1737,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 +1795,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 +2467,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 +2553,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 +2651,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(", ")} +

+ )} +
+ )}
)} @@ -2528,15 +2811,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 +3071,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..0962211 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,17 @@ 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, useSpellSlot } 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, +} from "@/lib/compendium/spell-slots" import { aggregateAsiBonuses } from "@/lib/builder/asi-allocation" import { normalizeFeatCategory } from "@/lib/builder/feat-selection" import { @@ -44,6 +56,10 @@ import { isWeaponItem, isWeaponProficient, } from "@/lib/compendium/combat-stats" +import { + getEffectiveArmorProficiencies, + getEffectiveWeaponProficiencies, +} from "@/lib/compendium/background-proficiencies" interface CharacterWithRelations extends Character { classes?: DndClass @@ -134,14 +150,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 +175,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 +192,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 +224,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 +256,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,8 +340,17 @@ 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 nonWeaponEquipment = equipment.filter((item) => !isWeaponItem(item)) + const filteredEquipment = filterEquipmentList(nonWeaponEquipment, equipmentSearchQuery) const spellcastingAbilityKey = ( character.classes?.spellcasting?.ability ?? character.subclasses?.spellcasting?.ability )?.toLowerCase() as keyof typeof abilityMods | undefined @@ -287,6 +367,22 @@ export default function CharacterSheetClient({ id }: { id: string }) { const formatMod = (mod: number) => (mod >= 0 ? `+${mod}` : `${mod}`) + 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 +408,16 @@ export default function CharacterSheetClient({ id }: { id: string }) { {character.banner_url && ( - +
+ +
)}
-
+
- {conditionDropdownOpen && ( -
+ {conditionDropdownOpen && conditionMenuPos && ( + <> +
setConditionDropdownOpen(false)} + /> +
{CONDITIONS.map((condition) => (
+
+ )} {activeConditions.length > 0 && (
{activeConditions.map((condName) => ( c.name === condName)?.description ?? + (isConcentrationCondition(condName) ? "Concentrating on a spell" : undefined) + } > {condName} + ))} +
- )) + ))} +
) : ( -

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 +1114,8 @@ export default function CharacterSheetClient({ id }: { id: string }) {

General Feats & Epic Boons

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

{feat.name} @@ -905,6 +1169,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 = useSpellSlot( + 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 && ( { 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 */}