Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 58 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -315,14 +349,19 @@ 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

This project uses content from the D&D 5.5e Systems Reference Document (SRD) under the Creative Commons license.

## Links

- [Continue developing on v0](https://v0.app/chat/projects/prj_Z07M3vx9HphfTfMDkIp9oqtpaHYN)
- [Next.js Documentation](https://nextjs.org/docs)
- [Tailwind CSS](https://tailwindcss.com)
8 changes: 8 additions & 0 deletions app/api/data/[table]/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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") {
Expand Down
6 changes: 5 additions & 1 deletion app/api/import/pdf/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
6 changes: 5 additions & 1 deletion app/api/import/text/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
58 changes: 33 additions & 25 deletions app/api/import/web/route.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const response = await fetch(url, {
Expand Down Expand Up @@ -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<string, number> = {}
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,
}
}

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading