Compare commits

..

15 Commits

Author SHA1 Message Date
Alexander Harding 6f3cd9919b Remove sign in banner 2026-07-31 23:28:55 -04:00
Alexander Harding 13aae7fff8 Fixed useSearchParams not in suspense 2026-07-31 23:26:44 -04:00
Alexander Harding b33791103b Made every character sheet sharable with the link by default 2026-07-31 23:06:38 -04:00
Alexander Harding 0ac0b88f8c Added Character Sheet and evolved Dice roller 2026-07-31 22:31:43 -04:00
Alexander Harding 1f53b0767f test 2026-07-31 21:16:48 -04:00
Alexander Harding 7144ac8fed build fixes 2026-07-31 20:56:22 -04:00
Alexander Harding 749dbcfe55 Rewrite Dockerfile 2026-07-31 20:37:45 -04:00
Alexander Harding 687b0e251a swap from node:22-slim to node:latest 2026-07-31 20:30:39 -04:00
Alexander Harding d7c87bca5c Change docker-compose to remove dedicated mongodb 2026-07-31 20:25:40 -04:00
Alexander Harding 6114d34c22 slight updates 2026-07-31 20:23:38 -04:00
Alexander Harding 9adaca1c7d Add Docker stuff 2026-07-31 20:14:35 -04:00
Alexander Harding a3ea76fdff Vibecode central 2026-07-31 19:50:55 -04:00
Alexander Harding 1362f28585 feat: add create-character page component
Co-authored-by: aider (ollama/qwen2.5-coder:7b) <aider@aider.chat>
2026-07-30 18:33:03 -04:00
Alexander Harding a6dfad20c2 feat: add character creation page components and hooks
Co-authored-by: aider (ollama/qwen2.5-coder:7b) <aider@aider.chat>
2026-07-30 18:27:51 -04:00
Alexander Harding 275047fbfb refactor: replace dice roller with menu button in home page 2026-07-30 18:27:43 -04:00
69 changed files with 7570 additions and 125 deletions
+19
View File
@@ -0,0 +1,19 @@
node_modules
.next
.git
.gitignore
.env
.env.local
.env.*.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
README.md
Dockerfile
.dockerignore
docker-compose.yml
docker-compose.*.yml
memory
request.json
*.log
+1
View File
@@ -39,3 +39,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.aider*
+20
View File
@@ -0,0 +1,20 @@
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
+13
View File
@@ -0,0 +1,13 @@
services:
app:
build: .
container_name: dndextended-app
restart: unless-stopped
ports:
- "${APP_PORT:-3000}:3000"
environment:
NODE_ENV: production
MONGODB_URI: ${MONGODB_URI:-mongodb://mongo:27017/dndextended}
AUTH_SECRET: ${AUTH_SECRET}
AUTH_URL: ${AUTH_URL}
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
+2 -1
View File
@@ -1,7 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
devIndicators: false,
output: "standalone",
};
export default nextConfig;
+448 -28
View File
@@ -8,18 +8,25 @@
"name": "dndextended",
"version": "0.1.0",
"dependencies": {
"@hookform/resolvers": "^5.5.7",
"@iconify-react/fa6-solid": "^1.0.2",
"@iconify-react/game-icons": "^1.0.3",
"@iconify-react/mdi": "^1.0.2",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"bcryptjs": "^3.0.3",
"mongodb": "^7.5.0",
"next": "16.2.10",
"next-auth": "^5.0.0-beta.31",
"react": "19.2.4",
"react-dom": "19.2.4",
"three": "^0.185.1"
"react-hook-form": "^7.83.0",
"three": "^0.185.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -42,6 +49,35 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@auth/core": {
"version": "0.41.2",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz",
"integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.2.1",
"jose": "^6.0.6",
"oauth4webapi": "^3.3.0",
"preact": "10.24.3",
"preact-render-to-string": "6.5.11"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^7.0.7"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -437,6 +473,30 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint/js": {
"version": "9.39.4",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
@@ -474,6 +534,116 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@hookform/resolvers": {
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.5.7.tgz",
"integrity": "sha512-CyPCYV8/KlXfEXLWj8HHHhVsR/IZ6Ckm3b/a4fWtO/lRnRK1huqncb8LlAWrmpPRsse5glF5aVuDRMHEr3UGag==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"@sinclair/typebox": ">=0.25.24",
"@standard-schema/spec": "^1.0.0",
"@typeschema/main": ">=0.13.7",
"@vinejs/vine": "^2.0.0 || ^3.0.0",
"ajv": "^8.12.0",
"ajv-errors": "^3.0.0",
"ajv-formats": "^2.1.1",
"arktype": "^2.0.0",
"ata-validator": "^0.7.0",
"class-transformer": ">=0.4.0",
"class-validator": ">=0.12.0",
"computed-types": "^1.0.0",
"effect": "^3.10.3",
"fluentvalidation-ts": "^3.0.0",
"fp-ts": "^2.7.0",
"io-ts": "^2.0.0",
"joi": "^17.0.0",
"nope-validator": ">=0.12.0",
"react-hook-form": "^7.55.0",
"superstruct": ">=0.12.0",
"typanion": "^3.3.2",
"valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc",
"vest": ">=3.0.0",
"yup": "^1.0.0",
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"@sinclair/typebox": {
"optional": true
},
"@standard-schema/spec": {
"optional": true
},
"@typeschema/main": {
"optional": true
},
"@vinejs/vine": {
"optional": true
},
"ajv": {
"optional": true
},
"ajv-errors": {
"optional": true
},
"ajv-formats": {
"optional": true
},
"arktype": {
"optional": true
},
"ata-validator": {
"optional": true
},
"class-transformer": {
"optional": true
},
"class-validator": {
"optional": true
},
"computed-types": {
"optional": true
},
"effect": {
"optional": true
},
"fluentvalidation-ts": {
"optional": true
},
"fp-ts": {
"optional": true
},
"io-ts": {
"optional": true
},
"joi": {
"optional": true
},
"nope-validator": {
"optional": true
},
"superstruct": {
"optional": true
},
"typanion": {
"optional": true
},
"valibot": {
"optional": true
},
"vest": {
"optional": true
},
"yup": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1158,6 +1328,15 @@
"integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
"license": "Apache-2.0"
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.4.12",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.12.tgz",
"integrity": "sha512-QAfAMwNgnYxZ2C6D1HgeP7Gc4i/uvJRim415PCIL9ptRxWMNbWeLBYb2/9R4pGKny/s1FVu2JA2cxCUBUOggrA==",
"license": "MIT",
"dependencies": {
"sparse-bitfield": "^3.0.3"
}
},
"node_modules/@monogrid/gainmap-js": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
@@ -1393,6 +1572,15 @@
"node": ">=12.4.0"
}
},
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@react-three/drei": {
"version": "10.7.7",
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
@@ -1488,6 +1676,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1797,6 +1991,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/draco3d": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
@@ -1888,12 +2089,27 @@
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
"license": "MIT"
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@types/whatwg-url": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz",
"integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==",
"license": "MIT",
"dependencies": {
"@types/webidl-conversions": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz",
@@ -2584,23 +2800,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -2886,6 +3085,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -2953,6 +3161,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/bson": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/bson/-/bson-7.3.1.tgz",
"integrity": "sha512-h/C0qe6857pQhcSJHLfsR1uYGj98Ge3wKAD3Ed9KqH3wcVh+BM4Jq4xISD7vs9OPuT07n+q3QQVjslJ286j6ag==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -3932,6 +4149,30 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/eslint/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -4000,7 +4241,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@@ -5031,6 +5272,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5081,13 +5331,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -5526,6 +5769,12 @@
"node": ">= 0.4"
}
},
"node_modules/memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -5588,6 +5837,65 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mongodb": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.5.0.tgz",
"integrity": "sha512-5FnrEDLnvp6ycUOGLNLLU33BfCx2qmp2mJjGPDwKLruYsVzXVSK5fsGpoDXvsXJwBfBsD7ebMRdawbDxC2814g==",
"license": "Apache-2.0",
"dependencies": {
"@mongodb-js/saslprep": "^1.4.11",
"bson": "^7.2.0",
"mongodb-connection-string-url": "^7.0.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@aws-sdk/credential-providers": "^3.806.0",
"@mongodb-js/zstd": "^7.0.0",
"gcp-metadata": "^7.0.1",
"kerberos": "^7.0.0",
"mongodb-client-encryption": "^7.2.0",
"snappy": "^7.3.2",
"socks": "^2.8.6"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-providers": {
"optional": true
},
"@mongodb-js/zstd": {
"optional": true
},
"gcp-metadata": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"snappy": {
"optional": true
},
"socks": {
"optional": true
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz",
"integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/whatwg-url": "^13.0.0",
"whatwg-url": "^14.1.0"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5689,6 +5997,33 @@
}
}
},
"node_modules/next-auth": {
"version": "5.0.0-beta.31",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.31.tgz",
"integrity": "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==",
"license": "ISC",
"dependencies": {
"@auth/core": "0.41.2"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
"nodemailer": "^7.0.7",
"react": "^18.2.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -5746,6 +6081,15 @@
"node": ">=18"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.6",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -6040,6 +6384,25 @@
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
"license": "ISC"
},
"node_modules/preact": {
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
"license": "MIT",
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6076,7 +6439,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6124,6 +6486,22 @@
"react": "^19.2.4"
}
},
"node_modules/react-hook-form": {
"version": "7.83.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.83.0.tgz",
"integrity": "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -6562,6 +6940,15 @@
"node": ">=0.10.0"
}
},
"node_modules/sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"license": "MIT",
"dependencies": {
"memory-pager": "^1.0.2"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -6924,6 +7311,18 @@
"node": ">=8.0"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/troika-three-text": {
"version": "0.52.4",
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
@@ -7299,6 +7698,28 @@
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -7437,7 +7858,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
+8 -1
View File
@@ -9,18 +9,25 @@
"lint": "eslint"
},
"dependencies": {
"@hookform/resolvers": "^5.5.7",
"@iconify-react/fa6-solid": "^1.0.2",
"@iconify-react/game-icons": "^1.0.3",
"@iconify-react/mdi": "^1.0.2",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"bcryptjs": "^3.0.3",
"mongodb": "^7.5.0",
"next": "16.2.10",
"next-auth": "^5.0.0-beta.31",
"react": "19.2.4",
"react-dom": "19.2.4",
"three": "^0.185.1"
"react-hook-form": "^7.83.0",
"three": "^0.185.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { useDiceContext } from "@/components/DiceContext";
import { Button } from "@/components/DiceSelector";
import React from "react";
export default function ManualDiceLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const { rollerRef } = useDiceContext();
return (
<>
{children}
<div className="fixed bottom-4 right-4 z-[10000] pointer-events-auto">
<Button rollerRef={rollerRef} />
</div>
</>
);
}
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { hash } from "bcryptjs";
import clientPromise from "@/lib/mongodb";
export async function POST(request: Request) {
const { name, email, password } = await request.json();
if (!name || !email || !password) {
return NextResponse.json({ error: "Name, email, and password are required" }, { status: 400 });
}
const client = await clientPromise;
const db = client.db("dndextended");
const existing = await db.collection("users").findOne({ email });
if (existing) {
return NextResponse.json({ error: "Email already in use" }, { status: 409 });
}
const hashed = await hash(password, 12);
const result = await db.collection("users").insertOne({
name,
email,
password: hashed,
createdAt: new Date(),
});
return NextResponse.json({ id: result.insertedId }, { status: 201 });
}
+5
View File
@@ -0,0 +1,5 @@
import { NextRequest } from "next/server";
import { listContent, createContent } from "@/lib/contentApi";
export const GET = (req: NextRequest) => listContent("backgrounds", req);
export const POST = (req: NextRequest) => createContent("backgrounds", req);
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import clientPromise from "@/lib/mongodb";
const DB = "dndextended";
/**
* Public read endpoint. Anyone with the URL can view the character sheet.
* Bundles all referenced content docs so the client renders in a single fetch.
* Only referenced content is returned — no full lists exposed.
*/
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
const client = await clientPromise;
const db = client.db(DB);
const character = await db.collection("characters").findOne({ id });
if (!character) return NextResponse.json({ error: "Not found" }, { status: 404 });
const speciesIds = [character.speciesId].filter(Boolean);
const classIds = (character.classes ?? []).map((c: { classId: string }) => c.classId).filter(Boolean);
const subclassIds = (character.classes ?? []).map((c: { subclassId?: string }) => c.subclassId).filter(Boolean);
const backgroundIds = [character.backgroundId].filter(Boolean);
const spellIds = Object.values(character.magic?.spells ?? {}).flat() as string[];
const [speciesDocs, classDocs, subclassDocs, backgroundDocs, spellDocs] = await Promise.all([
speciesIds.length ? db.collection("species").find({ id: { $in: speciesIds } }).toArray() : [],
classIds.length ? db.collection("classes").find({ id: { $in: classIds } }).toArray() : [],
subclassIds.length ? db.collection("subclasses").find({ id: { $in: subclassIds } }).toArray() : [],
backgroundIds.length ? db.collection("backgrounds").find({ id: { $in: backgroundIds } }).toArray() : [],
spellIds.length ? db.collection("spells").find({ id: { $in: spellIds } }).toArray() : [],
]);
// Collect every feature id referenced by species, classes, subclasses, backgrounds, applied.
const featureIds = new Set<string>();
for (const s of speciesDocs) for (const fid of s.featureIds ?? []) featureIds.add(fid);
for (const b of backgroundDocs) for (const fid of b.featureIds ?? []) featureIds.add(fid);
for (const c of classDocs) for (const lf of c.levelFeatures ?? []) for (const fid of lf.featureIds ?? []) featureIds.add(fid);
for (const sc of subclassDocs) for (const lf of sc.levelFeatures ?? []) for (const fid of lf.featureIds ?? []) featureIds.add(fid);
for (const app of character.appliedFeatures ?? []) featureIds.add(app.featureId);
const featureDocs = featureIds.size
? await db.collection("features").find({ id: { $in: [...featureIds] } }).toArray()
: [];
return NextResponse.json({
character,
species: speciesDocs,
classes: classDocs,
subclasses: subclassDocs,
backgrounds: backgroundDocs,
features: featureDocs,
spells: spellDocs,
});
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import clientPromise from "@/lib/mongodb";
import { auth } from "@/lib/auth";
const DB = "dndextended";
export async function GET() {
const session = await auth();
if (!session) return NextResponse.json({ error: "Not Authenticated" }, { status: 401 });
const client = await clientPromise;
const results = await client
.db(DB)
.collection("characters")
.find({ user_id: session.user.id })
.toArray();
return NextResponse.json({ results });
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Not Authenticated" }, { status: 401 });
const body = await req.json();
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
if (!body.name) {
return NextResponse.json({ error: "name required" }, { status: 400 });
}
const doc = {
...body,
id: body.id ?? randomUUID(),
user_id: session.user.id,
userId: session.user.id,
createdAt: new Date().toISOString(),
};
const client = await clientPromise;
await client.db(DB).collection("characters").insertOne(doc);
return NextResponse.json({ result: doc }, { status: 201 });
}
+5
View File
@@ -0,0 +1,5 @@
import { NextRequest } from "next/server";
import { listContent, createContent } from "@/lib/contentApi";
export const GET = (req: NextRequest) => listContent("classes", req);
export const POST = (req: NextRequest) => createContent("classes", req);
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import clientPromise from "@/lib/mongodb";
import { auth } from "@/lib/auth";
const DB = "dndextended";
const VALID_PARENT = ["species", "class", "background", "subclass"];
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Not Authenticated" }, { status: 401 });
const { id } = await ctx.params;
const body = await req.json();
const userId = session.user.id;
const isAdmin = session.user.isAdmin === true;
const client = await clientPromise;
const col = client.db(DB).collection("features");
const doc = await col.findOne({ id });
if (!doc) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (doc.creatorId !== userId && !isAdmin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const patch: Record<string, unknown> = {};
if ("parentType" in body) {
if (body.parentType === null) patch.parentType = null;
else if (VALID_PARENT.includes(body.parentType)) patch.parentType = body.parentType;
else return NextResponse.json({ error: "Invalid parentType" }, { status: 400 });
}
if ("parentId" in body) {
patch.parentId = body.parentId === null ? null : String(body.parentId);
}
if (typeof body.name === "string") patch.name = body.name;
if (typeof body.description === "string") patch.description = body.description;
if (Array.isArray(body.effects)) patch.effects = body.effects;
if (Object.keys(patch).length === 0) {
return NextResponse.json({ result: doc });
}
await col.updateOne({ id }, { $set: patch });
const updated = await col.findOne({ id });
return NextResponse.json({ result: updated });
}
+5
View File
@@ -0,0 +1,5 @@
import { NextRequest } from "next/server";
import { listContent, createContent } from "@/lib/contentApi";
export const GET = (req: NextRequest) => listContent("features", req);
export const POST = (req: NextRequest) => createContent("features", req);
+5
View File
@@ -0,0 +1,5 @@
import { NextRequest } from "next/server";
import { listContent, createContent } from "@/lib/contentApi";
export const GET = (req: NextRequest) => listContent("species", req);
export const POST = (req: NextRequest) => createContent("species", req);
+5
View File
@@ -0,0 +1,5 @@
import { NextRequest } from "next/server";
import { listContent, createContent } from "@/lib/contentApi";
export const GET = (req: NextRequest) => listContent("spells", req);
export const POST = (req: NextRequest) => createContent("spells", req);
+5
View File
@@ -0,0 +1,5 @@
import { NextRequest } from "next/server";
import { listContent, createContent } from "@/lib/contentApi";
export const GET = (req: NextRequest) => listContent("subclasses", req);
export const POST = (req: NextRequest) => createContent("subclasses", req);
+193
View File
@@ -0,0 +1,193 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import type {
Ability,
AbilityScores,
Alignment,
Background,
CharacterClass,
ClassDef,
Feature,
RollMethod,
Ruleset,
Skill,
Species,
Spell,
Subclass,
} from "@/types/content";
export type AbilityMethod = "standardArray" | "pointBuy" | "rolled" | "manual";
export interface DraftCharacter {
name: string;
alignment?: Alignment;
speciesId?: string;
backgroundId?: string;
classes: CharacterClass[];
abilities: AbilityScores;
abilityMethod: AbilityMethod;
skillProficiencies: Skill[];
savingThrowProficiencies: Ability[];
hpMethod: RollMethod;
spellIds: string[];
featureChoices: Record<string, string[]>;
}
const DEFAULT_ABILITIES: AbilityScores = {
strength: 8,
dexterity: 8,
constitution: 8,
intelligence: 8,
wisdom: 8,
charisma: 8,
};
const DEFAULT_DRAFT: DraftCharacter = {
name: "",
classes: [],
abilities: DEFAULT_ABILITIES,
abilityMethod: "standardArray",
skillProficiencies: [],
savingThrowProficiencies: [],
hpMethod: "average",
spellIds: [],
featureChoices: {},
};
interface CreatorContextValue {
draft: DraftCharacter;
update: (partial: Partial<DraftCharacter>) => void;
step: number;
setStep: (n: number) => void;
activeRulesets: Ruleset[];
toggleRuleset: (r: Ruleset) => void;
species: Species[];
classes: ClassDef[];
backgrounds: Background[];
features: Record<string, Feature>;
spells: Spell[];
subclasses: Subclass[];
allSpecies: Species[];
allClasses: ClassDef[];
allBackgrounds: Background[];
refreshSpecies: () => Promise<void>;
refreshClasses: () => Promise<void>;
refreshBackgrounds: () => Promise<void>;
refreshFeatures: () => Promise<void>;
refreshSpells: () => Promise<void>;
refreshSubclasses: () => Promise<void>;
loading: boolean;
}
const Ctx = createContext<CreatorContextValue | null>(null);
export function useCreator() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useCreator must be inside CreatorProvider");
return ctx;
}
async function fetchList<T>(path: string): Promise<T[]> {
const r = await fetch(path);
if (!r.ok) return [];
const { results } = await r.json();
return results ?? [];
}
function inRulesets<T extends { ruleset?: Ruleset }>(items: T[], allowed: Ruleset[]): T[] {
if (allowed.length === 0) return [];
return items.filter((x) => allowed.includes(x.ruleset ?? "homebrew"));
}
export function CreatorProvider({ children }: { children: ReactNode }) {
const [draft, setDraft] = useState<DraftCharacter>(DEFAULT_DRAFT);
const [step, setStep] = useState(0);
const [activeRulesets, setActiveRulesets] = useState<Ruleset[]>(["5e", "homebrew"]);
const [speciesAll, setSpeciesAll] = useState<Species[]>([]);
const [classesAll, setClassesAll] = useState<ClassDef[]>([]);
const [backgroundsAll, setBackgroundsAll] = useState<Background[]>([]);
const [featureList, setFeatureList] = useState<Feature[]>([]);
const [spellsAll, setSpellsAll] = useState<Spell[]>([]);
const [subclassesAll, setSubclassesAll] = useState<Subclass[]>([]);
const [loading, setLoading] = useState(false);
const update = useCallback((partial: Partial<DraftCharacter>) => {
setDraft((d) => ({ ...d, ...partial }));
}, []);
const toggleRuleset = useCallback((r: Ruleset) => {
setActiveRulesets((prev) => (prev.includes(r) ? prev.filter((x) => x !== r) : [...prev, r]));
}, []);
const refreshSpecies = useCallback(async () => setSpeciesAll(await fetchList("/api/species")), []);
const refreshClasses = useCallback(async () => setClassesAll(await fetchList("/api/classes")), []);
const refreshBackgrounds = useCallback(
async () => setBackgroundsAll(await fetchList("/api/backgrounds")),
[]
);
const refreshFeatures = useCallback(
async () => setFeatureList(await fetchList("/api/features")),
[]
);
const refreshSpells = useCallback(async () => setSpellsAll(await fetchList("/api/spells")), []);
const refreshSubclasses = useCallback(
async () => setSubclassesAll(await fetchList("/api/subclasses")),
[]
);
const species = useMemo(() => inRulesets(speciesAll, activeRulesets), [speciesAll, activeRulesets]);
const classes = useMemo(() => inRulesets(classesAll, activeRulesets), [classesAll, activeRulesets]);
const backgrounds = useMemo(
() => inRulesets(backgroundsAll, activeRulesets),
[backgroundsAll, activeRulesets]
);
const spells = useMemo(() => inRulesets(spellsAll, activeRulesets), [spellsAll, activeRulesets]);
const subclasses = useMemo(() => inRulesets(subclassesAll, activeRulesets), [subclassesAll, activeRulesets]);
const features = useMemo(() => {
const dict: Record<string, Feature> = {};
for (const f of featureList) dict[f.id] = f;
return dict;
}, [featureList]);
const value: CreatorContextValue = {
draft,
update,
step,
setStep,
activeRulesets,
toggleRuleset,
species,
classes,
backgrounds,
features,
spells,
subclasses,
allSpecies: speciesAll,
allClasses: classesAll,
allBackgrounds: backgroundsAll,
refreshSpecies,
refreshClasses,
refreshBackgrounds,
refreshFeatures,
refreshSpells,
refreshSubclasses,
loading,
};
void setLoading;
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
+221
View File
@@ -0,0 +1,221 @@
"use client";
import { useEffect, useMemo } from "react";
import { RULESETS, RULESET_LABELS } from "@/types/content";
import { CreatorProvider, useCreator } from "./CreatorContext";
import SpeciesStep from "./steps/SpeciesStep";
import ClassStep from "./steps/ClassStep";
import SubclassStep from "./steps/SubclassStep";
import AbilitiesStep from "./steps/AbilitiesStep";
import BackgroundStep from "./steps/BackgroundStep";
import SkillsStep from "./steps/SkillsStep";
import SpellsStep from "./steps/SpellsStep";
import DetailsStep from "./steps/DetailsStep";
const STEPS = [
{ key: "species", label: "Species", Component: SpeciesStep },
{ key: "class", label: "Class", Component: ClassStep },
{ key: "subclass", label: "Subclass", Component: SubclassStep },
{ key: "abilities", label: "Abilities", Component: AbilitiesStep },
{ key: "background", label: "Background", Component: BackgroundStep },
{ key: "skills", label: "Skills", Component: SkillsStep },
{ key: "spells", label: "Spells", Component: SpellsStep },
{ key: "details", label: "Details", Component: DetailsStep },
];
function WizardInner() {
const {
step, setStep, draft, species, classes, backgrounds, subclasses, features,
activeRulesets, toggleRuleset,
refreshFeatures, refreshSubclasses,
} = useCreator();
useEffect(() => { refreshFeatures(); refreshSubclasses(); }, [refreshFeatures, refreshSubclasses]);
const Current = STEPS[step].Component;
const summary = useMemo(() => {
const s = species.find((x) => x.id === draft.speciesId)?.name ?? "—";
const c = classes.find((x) => x.id === draft.classes[0]?.classId)?.name ?? "—";
const b = backgrounds.find((x) => x.id === draft.backgroundId)?.name ?? "—";
return { s, c, b };
}, [species, classes, backgrounds, draft]);
const level = draft.classes[0]?.level ?? 1;
const chosenClass = classes.find((c) => c.id === draft.classes[0]?.classId);
const chosenSpecies = species.find((s) => s.id === draft.speciesId);
const chosenBg = backgrounds.find((b) => b.id === draft.backgroundId);
const chosenSubclass = useMemo(() => {
if (!chosenClass) return undefined;
const explicit = draft.classes[0]?.subclassId;
if (explicit) return subclasses.find((s) => s.id === explicit);
for (const lf of chosenClass.levelFeatures ?? []) {
if (lf.level > level) continue;
for (const fid of lf.featureIds) {
const f = features[fid];
if (f?.effects?.some((e) => e.target.kind === "grantSubclassChoice")) {
const pick = draft.featureChoices[fid]?.[0];
if (pick) return subclasses.find((s) => s.id === pick);
}
}
}
return undefined;
}, [chosenClass, draft, subclasses, features, level]);
const activeFeatureIds = useMemo(() => {
const ids: string[] = [];
ids.push(...(chosenSpecies?.featureIds ?? []));
ids.push(...(chosenBg?.featureIds ?? []));
for (const lf of chosenClass?.levelFeatures ?? []) {
if (lf.level <= level) ids.push(...lf.featureIds);
}
for (const lf of chosenSubclass?.levelFeatures ?? []) {
if (lf.level <= level) ids.push(...lf.featureIds);
}
return ids;
}, [chosenSpecies, chosenBg, chosenClass, chosenSubclass, level]);
const hasSubclassChoice = useMemo(() => {
if (!chosenClass) return false;
for (const lf of chosenClass.levelFeatures ?? []) {
if (lf.level > level) continue;
for (const fid of lf.featureIds) {
const f = features[fid];
if (f?.effects?.some(
(e) => e.target.kind === "grantSubclassChoice" && (e.level ?? 1) <= level
)) return true;
}
}
return false;
}, [chosenClass, level, features]);
const hasSpellcasting = useMemo(() => {
for (const fid of activeFeatureIds) {
const f = features[fid];
if (!f) continue;
if (f.effects?.some(
(e) => e.target.kind === "grantSpellcasting" && (e.level ?? 1) <= level
)) return true;
}
return false;
}, [activeFeatureIds, features, level]);
const stepEnabled = (key: string): boolean => {
if (key === "subclass") return hasSubclassChoice;
if (key === "spells") return hasSpellcasting;
return true;
};
const stepValid = (i: number): boolean => {
if (!stepEnabled(STEPS[i].key)) return false;
if (i === 0) return true;
if (i === 1) return !!draft.speciesId;
if (i >= 2) return !!draft.speciesId && !!draft.classes[0]?.classId;
return true;
};
const nextIndex = (from: number, dir: 1 | -1): number => {
let i = from + dir;
while (i > 0 && i < STEPS.length - 1 && !stepEnabled(STEPS[i].key)) i += dir;
return Math.max(0, Math.min(STEPS.length - 1, i));
};
return (
<div className="flex-1 flex flex-col md:flex-row">
<aside className="w-full md:w-56 border-r border-neutral-800 bg-neutral-950/60 p-4 md:sticky md:top-0 md:self-start md:max-h-screen md:overflow-y-auto">
<h1 className="text-lg font-bold text-neutral-100 mb-4">Create Character</h1>
<div className="mb-4">
<div className="text-xs uppercase tracking-wide text-neutral-500 mb-2">Rulesets</div>
<div className="flex flex-wrap gap-1">
{RULESETS.map((r) => {
const on = activeRulesets.includes(r);
return (
<button
key={r}
onClick={() => toggleRuleset(r)}
className={`px-2 py-1 text-xs rounded border transition ${
on
? "bg-amber-500/20 border-amber-500 text-amber-300"
: "border-neutral-700 text-neutral-500 hover:border-neutral-500"
}`}
>
{RULESET_LABELS[r]}
</button>
);
})}
</div>
</div>
<nav className="space-y-1">
{STEPS.map((s, i) => {
const active = i === step;
const enabled = stepEnabled(s.key);
const usable = stepValid(i);
const dimmed = !enabled;
return (
<button
key={s.key}
onClick={() => usable && setStep(i)}
disabled={!usable}
title={dimmed && s.key === "subclass" ? "No subclass choice at current class level" :
dimmed && s.key === "spells" ? "No spellcasting granted" : undefined}
className={`w-full text-left px-3 py-2 rounded text-sm transition ${
active
? "bg-amber-500/20 text-amber-300"
: usable
? "text-neutral-300 hover:bg-neutral-800"
: dimmed
? "text-neutral-600 italic cursor-not-allowed"
: "text-neutral-600 cursor-not-allowed"
}`}
>
<span className="text-xs text-neutral-500 mr-2">{i + 1}.</span>
{s.label}
{dimmed && <span className="ml-1 text-[10px] text-neutral-600">(n/a)</span>}
</button>
);
})}
</nav>
<div className="mt-8 p-3 border border-neutral-800 rounded text-xs space-y-1 text-neutral-400">
<div><span className="text-neutral-500">Name:</span> {draft.name || "—"}</div>
<div><span className="text-neutral-500">Species:</span> {summary.s}</div>
<div><span className="text-neutral-500">Class:</span> {summary.c} {draft.classes[0]?.level ? `Lv ${draft.classes[0].level}` : ""}</div>
<div><span className="text-neutral-500">Background:</span> {summary.b}</div>
</div>
</aside>
<main className="flex-1 p-6 md:p-10 max-w-5xl">
<Current />
<div className="mt-10 flex justify-between border-t border-neutral-800 pt-4">
<button
onClick={() => setStep(nextIndex(step, -1))}
disabled={step === 0}
className="px-4 py-2 text-sm text-neutral-300 disabled:opacity-30 hover:text-neutral-100"
>
Back
</button>
{step < STEPS.length - 1 && (
<button
onClick={() => setStep(nextIndex(step, 1))}
disabled={!stepValid(nextIndex(step, 1))}
className="px-4 py-2 text-sm bg-neutral-800 hover:bg-neutral-700 disabled:opacity-30 rounded"
>
Next
</button>
)}
</div>
</main>
</div>
);
}
export default function Wizard() {
return (
<CreatorProvider>
<WizardInner />
</CreatorProvider>
);
}
@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import type { Feature } from "@/types/content";
import Modal from "./Modal";
import FeatureBuilder from "./FeatureBuilder";
interface Props {
source: Feature["source"];
parentType?: "species" | "class" | "background" | "subclass";
parentId?: string;
available: Feature[];
attachedIds: string[];
onChange: (ids: string[]) => void;
onFeatureCreated: (f: Feature) => void;
}
export default function AttachedFeatures({
source,
parentType,
parentId,
available,
attachedIds,
onChange,
onFeatureCreated,
}: Props) {
const [pickerOpen, setPickerOpen] = useState(false);
const [builderOpen, setBuilderOpen] = useState(false);
const [search, setSearch] = useState("");
const attached = attachedIds
.map((id) => available.find((f) => f.id === id))
.filter((f): f is Feature => !!f);
const filtered = available
.filter((f) => f.source === source || source === "custom")
.filter((f) => !attachedIds.includes(f.id))
.filter((f) => {
const isOrphan = !f.parentType && !f.parentId;
const ownedByThisParent =
parentType && parentId && f.parentType === parentType && f.parentId === parentId;
return isOrphan || ownedByThisParent;
})
.filter((f) => f.name.toLowerCase().includes(search.toLowerCase()));
const remove = (id: string) => onChange(attachedIds.filter((x) => x !== id));
const add = (id: string) => onChange([...attachedIds, id]);
return (
<div className="space-y-2">
{attached.length === 0 && (
<p className="text-sm text-neutral-500 italic">No features attached.</p>
)}
{attached.map((f) => (
<div key={f.id} className="flex items-start justify-between p-2 bg-neutral-950/50 border border-neutral-700 rounded">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-neutral-100">
{f.name}
{f.level != null && <span className="ml-2 text-xs text-neutral-500">Lv {f.level}</span>}
{f.effects?.length > 0 && (
<span className="ml-2 text-xs text-amber-400">{f.effects.length} effect(s)</span>
)}
</div>
{f.description && <div className="text-xs text-neutral-400 line-clamp-2">{f.description}</div>}
</div>
<button
onClick={() => remove(f.id)}
className="text-red-400 hover:text-red-300 text-xs px-2"
type="button"
>
Remove
</button>
</div>
))}
<div className="flex gap-2">
<button
type="button"
onClick={() => setPickerOpen(true)}
className="text-sm text-neutral-300 border border-neutral-600 hover:border-neutral-400 rounded px-3 py-1"
>
Attach existing
</button>
<button
type="button"
onClick={() => setBuilderOpen(true)}
className="text-sm text-amber-400 border border-amber-400/40 hover:bg-amber-400/10 rounded px-3 py-1"
>
+ Create feature
</button>
</div>
<Modal open={pickerOpen} onClose={() => setPickerOpen(false)} title="Attach existing feature">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search..."
className="w-full mb-3 bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
<div className="max-h-96 overflow-y-auto space-y-2">
{filtered.length === 0 && <p className="text-sm text-neutral-500 italic">No matches.</p>}
{filtered.map((f) => (
<button
key={f.id}
onClick={() => { add(f.id); setPickerOpen(false); }}
className="w-full text-left p-2 border border-neutral-700 rounded hover:border-amber-500"
type="button"
>
<div className="text-sm font-medium">{f.name}</div>
{f.description && <div className="text-xs text-neutral-400 line-clamp-1">{f.description}</div>}
</button>
))}
</div>
</Modal>
<Modal open={builderOpen} onClose={() => setBuilderOpen(false)} title="Create feature" wide confirmClose>
<FeatureBuilder
source={source}
parentType={parentType}
parentId={parentId}
onCancel={() => setBuilderOpen(false)}
onSave={(f) => {
onFeatureCreated(f);
add(f.id);
setBuilderOpen(false);
}}
/>
</Modal>
</div>
);
}
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { Background, Feature, RULESETS, RULESET_LABELS, Ruleset, Skill, SKILL_ABILITY } from "@/types/content";
import { useCreator } from "../CreatorContext";
import { useIsAdmin } from "@/hooks/useIsAdmin";
import AttachedFeatures from "./AttachedFeatures";
import { claimFeaturesForParent } from "@/lib/claimFeatures";
const SKILLS = Object.keys(SKILL_ABILITY) as Skill[];
interface Props {
onSave: (b: Background) => void;
onCancel: () => void;
}
export default function BackgroundBuilder({ onSave, onCancel }: Props) {
const isAdmin = useIsAdmin();
const { features, refreshFeatures } = useCreator();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [skills, setSkills] = useState<Skill[]>([]);
const [toolsStr, setToolsStr] = useState("");
const [languagesStr, setLanguagesStr] = useState("");
const [featureIds, setFeatureIds] = useState<string[]>([]);
const [visibility, setVisibility] = useState<"private" | "public">("private");
const [ruleset, setRuleset] = useState<Ruleset>("5e");
const [busy, setBusy] = useState(false);
const available = Object.values(features);
const toggle = <T,>(arr: T[], v: T): T[] =>
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
const handleFeatureCreated = async (_f: Feature) => {
await refreshFeatures();
};
const submit = async () => {
if (!name.trim()) return;
setBusy(true);
const payload = {
name: name.trim(),
description: description.trim(),
skillProficiencies: skills,
toolProficiencies: toolsStr.split(",").map((s) => s.trim()).filter(Boolean),
languages: languagesStr.split(",").map((s) => s.trim()).filter(Boolean),
featureIds,
visibility,
ruleset,
};
const res = await fetch("/api/backgrounds", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
setBusy(false);
return;
}
const { result } = await res.json();
await claimFeaturesForParent({
parentType: "background",
parentId: (result as Background).id,
featureIds,
featureDict: features,
});
await refreshFeatures();
setBusy(false);
onSave(result as Background);
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-neutral-400 mb-1">Name *</label>
<input value={name} onChange={(e) => setName(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Skill proficiencies</label>
<div className="flex flex-wrap gap-2">
{SKILLS.map((s) => (
<label key={s} className={`px-2 py-1 rounded border text-xs cursor-pointer ${skills.includes(s) ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}>
<input type="checkbox" className="hidden" checked={skills.includes(s)} onChange={() => setSkills(toggle(skills, s))} />
{s}
</label>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-neutral-400 mb-1">Tools (comma)</label>
<input value={toolsStr} onChange={(e) => setToolsStr(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Languages (comma)</label>
<input value={languagesStr} onChange={(e) => setLanguagesStr(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-2">Features</label>
<AttachedFeatures source="background" parentType="background" available={available} attachedIds={featureIds} onChange={setFeatureIds} onFeatureCreated={handleFeatureCreated} />
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Visibility:</label>
<select value={visibility} onChange={(e) => setVisibility(e.target.value as "private" | "public")} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
<option value="private">Private</option>
<option value="public">Public (admin)</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Ruleset:</label>
<select value={ruleset} onChange={(e) => setRuleset(e.target.value as Ruleset)} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
{RULESETS.map((r) => <option key={r} value={r}>{RULESET_LABELS[r]}</option>)}
</select>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onCancel} className="px-4 py-2 text-sm text-neutral-300 hover:text-neutral-100">Cancel</button>
<button
onClick={submit}
disabled={busy || !name.trim()}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
{busy ? "Saving..." : "Save Background"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,323 @@
"use client";
import { useState } from "react";
import { ABILITIES, Ability, ClassDef, Feature, RULESETS, RULESET_LABELS, Ruleset, Skill, SKILL_ABILITY } from "@/types/content";
import { useCreator } from "../CreatorContext";
import { useIsAdmin } from "@/hooks/useIsAdmin";
import AttachedFeatures from "./AttachedFeatures";
import { claimFeaturesForParent } from "@/lib/claimFeatures";
import InfoTip from "./InfoTip";
const PRIMARY_ABILITY_TABLE: [string, string][] = [
["Barbarian", "Str"],
["Bard", "Cha"],
["Cleric", "Wis"],
["Druid", "Wis"],
["Fighter", "Str or Dex"],
["Monk", "Dex + Wis"],
["Paladin", "Str + Cha"],
["Ranger", "Dex + Wis"],
["Rogue", "Dex"],
["Sorcerer", "Cha"],
["Warlock", "Cha"],
["Wizard", "Int"],
];
const SKILLS = Object.keys(SKILL_ABILITY) as Skill[];
interface Props {
onSave: (c: ClassDef) => void;
onCancel: () => void;
}
export default function ClassBuilder({ onSave, onCancel }: Props) {
const isAdmin = useIsAdmin();
const { features, refreshFeatures } = useCreator();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [hitSides, setHitSides] = useState(8);
const [primary, setPrimary] = useState<Ability[]>(["strength"]);
const [saves, setSaves] = useState<Ability[]>(["strength", "constitution"]);
const [skillCount, setSkillCount] = useState(2);
const [skillOptions, setSkillOptions] = useState<Skill[]>([]);
const [armorStr, setArmorStr] = useState("");
const [weaponStr, setWeaponStr] = useState("");
const [toolStr, setToolStr] = useState("");
const [featureIds, setFeatureIds] = useState<string[]>([]);
const [hasSpellcasting, setHasSpellcasting] = useState(false);
const [spellAbility, setSpellAbility] = useState<Ability>("intelligence");
const [visibility, setVisibility] = useState<"private" | "public">("private");
const [ruleset, setRuleset] = useState<Ruleset>("5e");
const [busy, setBusy] = useState(false);
const available = Object.values(features);
const toggle = <T,>(arr: T[], v: T): T[] =>
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
const handleFeatureCreated = async (_f: Feature) => {
await refreshFeatures();
};
const submit = async () => {
if (!name.trim()) return;
setBusy(true);
const attachedFeatures = featureIds
.map((id) => features[id])
.filter((f): f is Feature => !!f);
const levelFeatures: Record<number, string[]> = {};
for (const f of attachedFeatures) {
const lv = f.level ?? 1;
(levelFeatures[lv] ||= []).push(f.id);
}
const payload: Omit<ClassDef, "id"> = {
name: name.trim(),
description: description.trim(),
hitDice: { sides: hitSides, count: 1 },
primaryAbility: primary,
savingThrowProficiencies: saves,
skillProficiencyOptions: { count: skillCount, from: skillOptions },
armorProficiencies: armorStr.split(",").map((s) => s.trim()).filter(Boolean),
weaponProficiencies: weaponStr.split(",").map((s) => s.trim()).filter(Boolean),
toolProficiencies: toolStr.split(",").map((s) => s.trim()).filter(Boolean),
levelFeatures: Object.entries(levelFeatures).map(([lv, ids]) => ({
level: Number(lv),
featureIds: ids,
})),
...(hasSpellcasting ? { spellcasting: { ability: spellAbility } } : {}),
visibility,
ruleset,
};
const res = await fetch("/api/classes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
setBusy(false);
return;
}
const { result } = await res.json();
await claimFeaturesForParent({
parentType: "class",
parentId: (result as ClassDef).id,
featureIds,
featureDict: features,
});
await refreshFeatures();
setBusy(false);
onSave(result as ClassDef);
};
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="block text-sm text-neutral-400 mb-1">Name *</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Hit die (d?)</label>
<select
value={hitSides}
onChange={(e) => setHitSides(Number(e.target.value))}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
>
{[6, 8, 10, 12].map((n) => <option key={n} value={n}>d{n}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 flex items-center gap-2">
<span>Primary ability</span>
<InfoTip>
<div className="mb-2 text-neutral-400">
Not in 2014 PHB tables narrative in each class&apos;s Quick Build. 2024 PHB formalized it. Common values:
</div>
<table className="w-full text-[11px] border-collapse">
<tbody>
{PRIMARY_ABILITY_TABLE.map(([cls, ab]) => (
<tr key={cls} className="border-b border-neutral-800 last:border-0">
<td className="py-0.5 pr-3 text-neutral-300">{cls}</td>
<td className="py-0.5 text-amber-300">{ab}</td>
</tr>
))}
</tbody>
</table>
</InfoTip>
</label>
<div className="flex flex-wrap gap-2">
{ABILITIES.map((a) => (
<label key={a} className={`px-2 py-1 rounded border text-xs cursor-pointer ${primary.includes(a) ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}>
<input type="checkbox" className="hidden" checked={primary.includes(a)} onChange={() => setPrimary(toggle(primary, a))} />
{a}
</label>
))}
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Saving throw proficiencies</label>
<div className="flex flex-wrap gap-2">
{ABILITIES.map((a) => (
<label key={a} className={`px-2 py-1 rounded border text-xs cursor-pointer ${saves.includes(a) ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}>
<input type="checkbox" className="hidden" checked={saves.includes(a)} onChange={() => setSaves(toggle(saves, a))} />
{a}
</label>
))}
</div>
</div>
<div className="p-3 border border-neutral-700 rounded space-y-3">
<div className="text-sm font-medium text-neutral-300">Starting Skill Proficiencies</div>
<div className="flex items-center gap-2">
<label className="text-xs text-neutral-400">Player picks</label>
<input
type="number"
min={0}
max={6}
value={skillCount}
onChange={(e) => setSkillCount(Number(e.target.value) || 0)}
className="w-16 bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-center"
/>
<label className="text-xs text-neutral-400">skill(s) from the pool below</label>
</div>
<div>
<div className="text-xs text-neutral-500 mb-1">Eligible skill pool</div>
<div className="flex flex-wrap gap-2">
{SKILLS.map((s) => (
<label key={s} className={`px-2 py-1 rounded border text-xs cursor-pointer ${skillOptions.includes(s) ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}>
<input type="checkbox" className="hidden" checked={skillOptions.includes(s)} onChange={() => setSkillOptions(toggle(skillOptions, s))} />
{s}
</label>
))}
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-sm text-neutral-400 mb-1 flex items-center gap-2">
<span>Armor proficiencies</span>
<InfoTip>
<div className="space-y-1">
<div className="text-neutral-400">Comma-separated. Use lowercase.</div>
<div><span className="text-amber-300">Categories:</span> <code>light</code>, <code>medium</code>, <code>heavy</code>, <code>shields</code></div>
<div className="text-neutral-500">Example: <code>light, medium, shields</code></div>
</div>
</InfoTip>
</label>
<input value={armorStr} onChange={(e) => setArmorStr(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" placeholder="light, medium, shields" />
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 flex items-center gap-2">
<span>Weapon proficiencies</span>
<InfoTip>
<div className="space-y-1">
<div className="text-neutral-400">Comma-separated. Use lowercase, singular PHB names.</div>
<div><span className="text-amber-300">Categories:</span> <code>simple</code>, <code>martial</code></div>
<div><span className="text-amber-300">Specific weapons:</span> use PHB singular name <code>hand crossbow</code>, <code>longsword</code>, <code>rapier</code>, <code>shortbow</code></div>
<div className="text-neutral-500">Example (Rogue): <code>simple, hand crossbow, longsword, rapier, shortsword</code></div>
</div>
</InfoTip>
</label>
<input value={weaponStr} onChange={(e) => setWeaponStr(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" placeholder="simple, hand crossbow, longsword" />
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 flex items-center gap-2">
<span>Tool proficiencies</span>
<InfoTip>
<div className="space-y-1">
<div className="text-neutral-400">Comma-separated. Full PHB name, lowercase.</div>
<div><span className="text-amber-300">Examples:</span></div>
<ul className="list-disc list-inside text-neutral-300">
<li><code>thieves&apos; tools</code></li>
<li><code>disguise kit</code></li>
<li><code>herbalism kit</code></li>
<li><code>smith&apos;s tools</code></li>
<li><code>dragonchess set</code></li>
<li><code>vehicles (land)</code></li>
</ul>
</div>
</InfoTip>
</label>
<input value={toolStr} onChange={(e) => setToolStr(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" placeholder="thieves' tools, disguise kit" />
</div>
</div>
<div className="p-3 border border-neutral-700 rounded">
<label className="flex items-center gap-2 text-sm text-neutral-300">
<input type="checkbox" checked={hasSpellcasting} onChange={(e) => setHasSpellcasting(e.target.checked)} />
Spellcasting
</label>
{hasSpellcasting && (
<div className="mt-2">
<label className="block text-xs text-neutral-400 mb-1">Spellcasting ability</label>
<select value={spellAbility} onChange={(e) => setSpellAbility(e.target.value as Ability)} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
{ABILITIES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</div>
)}
</div>
<div>
<label className="block text-sm text-neutral-400 mb-2">Class Features (set the &quot;level&quot; on each)</label>
<AttachedFeatures
source="class"
parentType="class"
available={available}
attachedIds={featureIds}
onChange={setFeatureIds}
onFeatureCreated={handleFeatureCreated}
/>
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Visibility:</label>
<select value={visibility} onChange={(e) => setVisibility(e.target.value as "private" | "public")} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
<option value="private">Private</option>
<option value="public">Public (admin)</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Ruleset:</label>
<select value={ruleset} onChange={(e) => setRuleset(e.target.value as Ruleset)} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
{RULESETS.map((r) => <option key={r} value={r}>{RULESET_LABELS[r]}</option>)}
</select>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onCancel} className="px-4 py-2 text-sm text-neutral-300 hover:text-neutral-100">Cancel</button>
<button
onClick={submit}
disabled={busy || !name.trim()}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
{busy ? "Saving..." : "Save Class"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,600 @@
"use client";
import { useEffect, useState } from "react";
import { ABILITIES, Ability, Skill, SKILL_ABILITY } from "@/types/content";
import type { AdvantageScope, Effect, EffectTarget } from "@/types/core";
import SpellProgressionTable from "./SpellProgressionTable";
/**
* Comma-list input with a local string buffer so typing spaces and multi-word items
* (like "thieves' tools") doesn't get eaten while typing.
*/
function CommaListInput({
value,
onCommit,
placeholder,
className,
}: {
value: string[];
onCommit: (next: string[]) => void;
placeholder?: string;
className?: string;
}) {
const [buf, setBuf] = useState(value.join(", "));
useEffect(() => {
setBuf(value.join(", "));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value.join("\u0001")]);
const commit = () => {
const parsed = buf.split(",").map((s) => s.trim()).filter(Boolean);
onCommit(parsed);
};
return (
<input
type="text"
value={buf}
onChange={(e) => setBuf(e.target.value)}
onBlur={commit}
placeholder={placeholder}
className={className}
/>
);
}
const SKILLS = Object.keys(SKILL_ABILITY) as Skill[];
const TARGET_KINDS: EffectTarget["kind"][] = [
"ability",
"abilityScoreChoice",
"speed",
"hpMax",
"armorClass",
"initiative",
"savingThrowProficiency",
"skillProficiency",
"skillProficiencyChoice",
"skillExpertise",
"toolExpertise",
"expertiseChoice",
"armorProficiency",
"weaponProficiency",
"toolProficiency",
"language",
"resistance",
"immunity",
"vulnerability",
"grantSpell",
"grantSpellcasting",
"grantFeat",
"extraAttack",
"grantSubclassChoice",
"advantage",
"disadvantage",
"action",
"bonusAction",
"reaction",
"custom",
];
const KIND_LABELS: Record<EffectTarget["kind"], string> = {
ability: "Ability score",
abilityScoreChoice: "Ability score improvement (choose)",
speed: "Speed",
hpMax: "HP Max",
armorClass: "Armor class",
initiative: "Initiative",
savingThrowProficiency: "Saving throw prof.",
skillProficiency: "Skill proficiency",
skillProficiencyChoice: "Skill proficiency (choose N)",
skillExpertise: "Skill expertise",
toolExpertise: "Tool expertise",
expertiseChoice: "Expertise (choose N)",
armorProficiency: "Armor proficiency",
weaponProficiency: "Weapon proficiency",
toolProficiency: "Tool proficiency",
language: "Language",
resistance: "Damage resistance",
immunity: "Damage immunity",
vulnerability: "Damage vulnerability",
grantSpell: "Grant spell",
grantSpellcasting: "Grant spellcasting",
grantFeat: "Grant feat",
extraAttack: "Extra attack",
grantSubclassChoice: "Grant subclass choice",
advantage: "Advantage",
disadvantage: "Disadvantage",
action: "Action",
bonusAction: "Bonus Action",
reaction: "Reaction",
custom: "Custom note",
};
const ADV_SCOPES = ["attack", "save", "skill", "check"] as const;
const SCOPE_LABELS: Record<(typeof ADV_SCOPES)[number], string> = {
attack: "attack rolls",
save: "saving throws",
skill: "skill check",
check: "ability checks",
};
function defaultTargetFor(kind: EffectTarget["kind"]): EffectTarget {
switch (kind) {
case "ability": return { kind, ability: "strength" };
case "abilityScoreChoice": return { kind, points: 2, maxPerAbility: 2, cap: 20 };
case "speed":
case "hpMax":
case "armorClass":
case "initiative": return { kind };
case "savingThrowProficiency": return { kind, ability: "strength" };
case "skillProficiency":
case "skillExpertise": return { kind, skill: "athletics" };
case "skillProficiencyChoice": return { kind, count: 2, from: [] };
case "toolExpertise": return { kind, tool: "" };
case "expertiseChoice": return { kind, count: 2, skillPool: "characterSkills", toolPool: [] };
case "armorProficiency":
case "weaponProficiency":
case "toolProficiency":
case "language": return { kind, value: "" };
case "resistance":
case "immunity":
case "vulnerability": return { kind, damageType: "fire" };
case "grantSpell": return { kind, spellId: "" };
case "grantSpellcasting": return {
kind,
ability: "intelligence",
progression: [{ level: 1, cantripsKnown: 0, spellsKnown: 0, slots: [] }],
};
case "grantFeat": return { kind, featId: "" };
case "extraAttack": return { kind, count: 1 };
case "grantSubclassChoice": return { kind, label: "" };
case "advantage":
case "disadvantage": return { kind, scope: { on: "attack" }, condition: "" };
case "action":
case "bonusAction":
case "reaction": return { kind, note: "" };
case "custom": return { kind, note: "" };
}
}
function defaultScope(on: AdvantageScope["on"]): AdvantageScope {
switch (on) {
case "attack": return { on };
case "save": return { on, ability: undefined };
case "skill": return { on, skill: "athletics" };
case "check": return { on, ability: undefined };
}
}
interface Props {
effects: Effect[];
onChange: (next: Effect[]) => void;
}
export default function EffectEditor({ effects, onChange }: Props) {
const update = (idx: number, patch: Partial<Effect>) => {
onChange(effects.map((e, i) => (i === idx ? { ...e, ...patch } : e)));
};
const updateTarget = (idx: number, target: EffectTarget) => update(idx, { target });
const remove = (idx: number) => onChange(effects.filter((_, i) => i !== idx));
const add = () =>
onChange([...effects, { target: { kind: "ability", ability: "strength" }, operation: "increase", value: 1 }]);
const needsValue = (kind: EffectTarget["kind"]) =>
["ability", "speed", "hpMax", "armorClass", "initiative"].includes(kind);
return (
<div className="space-y-3">
{effects.length === 0 && (
<p className="text-sm text-neutral-500 italic">No effects. Click below to add one.</p>
)}
{effects.map((eff, i) => {
const kind = eff.target.kind;
return (
<div key={i} className="flex flex-wrap items-center gap-2 p-3 border border-neutral-700 rounded bg-neutral-950/50">
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={kind}
onChange={(e) => updateTarget(i, defaultTargetFor(e.target.value as EffectTarget["kind"]))}
>
{TARGET_KINDS.map((k) => (
<option key={k} value={k}>{KIND_LABELS[k]}</option>
))}
</select>
{eff.target.kind === "ability" && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={eff.target.ability}
onChange={(e) => updateTarget(i, { kind: "ability", ability: e.target.value as Ability })}
>
{ABILITIES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
)}
{eff.target.kind === "abilityScoreChoice" && (() => {
const t = eff.target;
const setPatch = (patch: Partial<typeof t>) =>
updateTarget(i, { ...t, ...patch } as EffectTarget);
return (
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500">distribute</span>
<input
type="number"
min={1}
value={t.points}
onChange={(e) => setPatch({ points: Math.max(1, Number(e.target.value) || 1) })}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16"
/>
<span className="text-xs text-neutral-500">points, max</span>
<input
type="number"
min={1}
value={t.maxPerAbility}
onChange={(e) => setPatch({ maxPerAbility: Math.max(1, Number(e.target.value) || 1) })}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16"
/>
<span className="text-xs text-neutral-500">per ability, cap</span>
<input
type="number"
min={1}
placeholder="none"
value={t.cap ?? ""}
onChange={(e) =>
setPatch({ cap: e.target.value ? Math.max(1, Number(e.target.value)) : undefined })
}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16"
/>
</div>
);
})()}
{eff.target.kind === "savingThrowProficiency" && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={eff.target.ability}
onChange={(e) => updateTarget(i, { kind: "savingThrowProficiency", ability: e.target.value as Ability })}
>
{ABILITIES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
)}
{(eff.target.kind === "skillProficiency" || eff.target.kind === "skillExpertise") && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={eff.target.skill}
onChange={(e) => updateTarget(i, { kind: eff.target.kind as "skillProficiency" | "skillExpertise", skill: e.target.value as Skill })}
>
{SKILLS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
)}
{eff.target.kind === "toolExpertise" && (
<input
type="text"
placeholder="tool name (e.g. thieves' tools)"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-56"
value={eff.target.tool}
onChange={(e) => updateTarget(i, { kind: "toolExpertise", tool: e.target.value })}
/>
)}
{eff.target.kind === "expertiseChoice" && (() => {
const t = eff.target;
const setPatch = (patch: Partial<typeof t>) =>
updateTarget(i, { ...t, ...patch } as EffectTarget);
const skillPoolMode = t.skillPool === "characterSkills" ? "characterSkills" : "specific";
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500">choose</span>
<input
type="number"
min={1}
value={t.count}
onChange={(e) => setPatch({ count: Math.max(1, Number(e.target.value) || 1) })}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16"
/>
<span className="text-xs text-neutral-500">total from:</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-neutral-400">Skill pool:</span>
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={skillPoolMode}
onChange={(e) =>
setPatch({
skillPool: e.target.value === "characterSkills" ? "characterSkills" : [],
})
}
>
<option value="characterSkills">Any skill character is proficient in</option>
<option value="specific">Specific skills</option>
</select>
</div>
{Array.isArray(t.skillPool) && (
<div className="flex flex-wrap gap-1">
{SKILLS.map((s) => {
const arr = t.skillPool as Skill[];
const on = arr.includes(s);
return (
<label
key={s}
className={`px-2 py-0.5 text-xs rounded border cursor-pointer ${on ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}
>
<input
type="checkbox"
className="hidden"
checked={on}
onChange={() =>
setPatch({
skillPool: on ? arr.filter((x) => x !== s) : [...arr, s],
})
}
/>
{s}
</label>
);
})}
</div>
)}
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-400">Also eligible tools (comma):</span>
<CommaListInput
value={t.toolPool}
onCommit={(next) => setPatch({ toolPool: next })}
placeholder="thieves' tools"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1"
/>
</div>
</div>
);
})()}
{eff.target.kind === "skillProficiencyChoice" && (() => {
const t = eff.target;
const setCount = (n: number) =>
updateTarget(i, { kind: "skillProficiencyChoice", count: n, from: t.from });
const toggleFrom = (s: Skill) => {
const next = t.from.includes(s) ? t.from.filter((x) => x !== s) : [...t.from, s];
updateTarget(i, { kind: "skillProficiencyChoice", count: t.count, from: next });
};
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500">choose</span>
<input
type="number"
min={1}
max={t.from.length || 18}
value={t.count}
onChange={(e) => setCount(Math.max(1, Number(e.target.value) || 1))}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16"
/>
<span className="text-xs text-neutral-500">of {t.from.length} option(s)</span>
</div>
<div className="flex flex-wrap gap-1">
{SKILLS.map((s) => {
const on = t.from.includes(s);
return (
<label
key={s}
className={`px-2 py-0.5 text-xs rounded border cursor-pointer ${on ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}
>
<input type="checkbox" className="hidden" checked={on} onChange={() => toggleFrom(s)} />
{s}
</label>
);
})}
</div>
</div>
);
})()}
{(eff.target.kind === "armorProficiency" ||
eff.target.kind === "weaponProficiency" ||
eff.target.kind === "toolProficiency" ||
eff.target.kind === "language") && (
<input
type="text"
placeholder="value"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-40"
value={eff.target.value}
onChange={(e) => updateTarget(i, { kind: eff.target.kind as "armorProficiency" | "weaponProficiency" | "toolProficiency" | "language", value: e.target.value })}
/>
)}
{(eff.target.kind === "resistance" ||
eff.target.kind === "immunity" ||
eff.target.kind === "vulnerability") && (
<input
type="text"
placeholder="damage type"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-32"
value={eff.target.damageType}
onChange={(e) => updateTarget(i, { kind: eff.target.kind as "resistance" | "immunity" | "vulnerability", damageType: e.target.value })}
/>
)}
{eff.target.kind === "grantSpell" && (
<input
type="text"
placeholder="spell id"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-40"
value={eff.target.spellId}
onChange={(e) => updateTarget(i, { kind: "grantSpell", spellId: e.target.value })}
/>
)}
{eff.target.kind === "grantSpellcasting" && (() => {
const t = eff.target;
const setPatch = (patch: Partial<typeof t>) =>
updateTarget(i, { ...t, ...patch } as EffectTarget);
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500">Ability:</span>
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={t.ability}
onChange={(e) => setPatch({ ability: e.target.value as Ability })}
>
{ABILITIES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<label className="text-xs text-neutral-500 flex items-center gap-1">
<input
type="checkbox"
checked={t.ritual === true}
onChange={(e) => setPatch({ ritual: e.target.checked })}
/>
Ritual casting
</label>
</div>
<SpellProgressionTable
progression={t.progression}
onChange={(rows) => setPatch({ progression: rows })}
/>
</div>
);
})()}
{eff.target.kind === "grantFeat" && (
<input
type="text"
placeholder="feat id"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-40"
value={eff.target.featId}
onChange={(e) => updateTarget(i, { kind: "grantFeat", featId: e.target.value })}
/>
)}
{eff.target.kind === "grantSubclassChoice" && (
<input
type="text"
placeholder="label (e.g. Roguish Archetype)"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1"
value={eff.target.label ?? ""}
onChange={(e) => updateTarget(i, { kind: "grantSubclassChoice", label: e.target.value })}
/>
)}
{eff.target.kind === "extraAttack" && (
<input
type="number"
min={1}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-20"
value={eff.target.count}
onChange={(e) => updateTarget(i, { kind: "extraAttack", count: Number(e.target.value) || 1 })}
/>
)}
{(eff.target.kind === "advantage" || eff.target.kind === "disadvantage") && (() => {
const kindLocal = eff.target.kind;
const scope = eff.target.scope;
const cond = eff.target.condition ?? "";
const setScope = (next: AdvantageScope) =>
updateTarget(i, { kind: kindLocal, scope: next, condition: cond });
const setCond = (c: string) =>
updateTarget(i, { kind: kindLocal, scope, condition: c });
return (
<>
<span className="text-xs text-neutral-500">on</span>
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={scope.on}
onChange={(e) => setScope(defaultScope(e.target.value as AdvantageScope["on"]))}
>
{ADV_SCOPES.map((s) => <option key={s} value={s}>{SCOPE_LABELS[s]}</option>)}
</select>
{scope.on === "skill" && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={scope.skill}
onChange={(e) => setScope({ on: "skill", skill: e.target.value as Skill })}
>
{SKILLS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
)}
{(scope.on === "save" || scope.on === "check") && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={scope.ability ?? ""}
onChange={(e) => {
const val = e.target.value;
setScope({ on: scope.on, ability: val ? (val as Ability) : undefined } as AdvantageScope);
}}
>
<option value="">all</option>
{ABILITIES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
)}
<input
type="text"
placeholder="condition (optional, e.g. 'vs poison')"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1 min-w-[10rem]"
value={cond}
onChange={(e) => setCond(e.target.value)}
/>
</>
);
})()}
{(eff.target.kind === "action" || eff.target.kind === "bonusAction" || eff.target.kind === "reaction") && (
<input
type="text"
placeholder="note (e.g. 'Dash, Disengage, or Hide')"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1"
value={eff.target.note}
onChange={(e) => updateTarget(i, { kind: eff.target.kind as "action" | "bonusAction" | "reaction", note: e.target.value })}
/>
)}
{eff.target.kind === "custom" && (
<input
type="text"
placeholder="note"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1"
value={eff.target.note}
onChange={(e) => updateTarget(i, { kind: "custom", note: e.target.value })}
/>
)}
{needsValue(kind) && (
<>
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={eff.operation}
onChange={(e) => update(i, { operation: e.target.value as Effect["operation"] })}
>
<option value="increase">+</option>
<option value="decrease"></option>
<option value="set">=</option>
</select>
<input
type="number"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-20"
value={eff.value ?? 0}
onChange={(e) => update(i, { value: Number(e.target.value) })}
/>
</>
)}
<div className="ml-auto flex items-center gap-1">
<label className="text-[10px] text-neutral-500 uppercase">Lv</label>
<input
type="number"
min={1}
max={20}
placeholder="1"
value={eff.level ?? ""}
onChange={(e) => update(i, { level: e.target.value ? Number(e.target.value) : undefined })}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-14 text-center"
title="Minimum level for this effect to apply"
/>
<button
onClick={() => remove(i)}
className="text-red-400 hover:text-red-300 text-sm px-2"
type="button"
>
Remove
</button>
</div>
</div>
);
})}
<button
onClick={add}
type="button"
className="text-sm text-amber-400 border border-amber-400/40 hover:bg-amber-400/10 rounded px-3 py-1"
>
+ Add effect
</button>
</div>
);
}
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { RULESETS, RULESET_LABELS, type Feature, type Ruleset } from "@/types/content";
import type { Effect } from "@/types/core";
import { useIsAdmin } from "@/hooks/useIsAdmin";
import EffectEditor from "./EffectEditor";
interface Props {
source: Feature["source"];
parentType?: "species" | "class" | "background" | "subclass";
parentId?: string;
onSave: (feature: Feature) => void;
onCancel: () => void;
initial?: Partial<Feature>;
}
export default function FeatureBuilder({ source, parentType, parentId, onSave, onCancel, initial }: Props) {
const isAdmin = useIsAdmin();
const [name, setName] = useState(initial?.name ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [level, setLevel] = useState<number | undefined>(initial?.level);
const [effects, setEffects] = useState<Effect[]>(initial?.effects ?? []);
const [visibility, setVisibility] = useState<"private" | "public">(
(initial?.visibility as "private" | "public") ?? "private"
);
const [ruleset, setRuleset] = useState<Ruleset>((initial?.ruleset as Ruleset) ?? "5e");
const [busy, setBusy] = useState(false);
const submit = async () => {
if (!name.trim()) return;
setBusy(true);
const payload = {
name: name.trim(),
description: description.trim(),
source,
level,
effects,
visibility,
ruleset,
parentType: parentType ?? null,
parentId: parentId ?? null,
};
const res = await fetch("/api/features", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
setBusy(false);
if (!res.ok) return;
const { result } = await res.json();
onSave(result as Feature);
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-neutral-400 mb-1">Name *</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
{(source === "class" || source === "subclass") && (
<div>
<label className="block text-sm text-neutral-400 mb-1">
Class level feature first appears
</label>
<input
type="number"
min={1}
max={20}
value={level ?? ""}
onChange={(e) => setLevel(e.target.value ? Number(e.target.value) : undefined)}
className="w-24 bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
<p className="text-xs text-neutral-500 mt-1">
For scaling features (e.g. Sneak Attack, Extra Attack), leave this at the earliest level
and set per-effect &quot;Lv&quot; on individual effects below.
</p>
</div>
)}
<div>
<label className="block text-sm text-neutral-400 mb-1">Effects</label>
<EffectEditor effects={effects} onChange={setEffects} />
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Visibility:</label>
<select
value={visibility}
onChange={(e) => setVisibility(e.target.value as "private" | "public")}
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm"
>
<option value="private">Private</option>
<option value="public">Public (admin)</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Ruleset:</label>
<select
value={ruleset}
onChange={(e) => setRuleset(e.target.value as Ruleset)}
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm"
>
{RULESETS.map((r) => <option key={r} value={r}>{RULESET_LABELS[r]}</option>)}
</select>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onCancel} className="px-4 py-2 text-sm text-neutral-300 hover:text-neutral-100">
Cancel
</button>
<button
onClick={submit}
disabled={busy || !name.trim()}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
{busy ? "Saving..." : "Save Feature"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
interface Props {
children: ReactNode;
width?: number;
}
export default function InfoTip({ children, width = 320 }: Props) {
const iconRef = useRef<HTMLSpanElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const show = () => {
const el = iconRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const vw = window.innerWidth;
const centered = r.left + r.width / 2 - width / 2;
const clampedLeft = Math.max(8, Math.min(centered, vw - width - 8));
setPos({ top: r.bottom + 8, left: clampedLeft });
};
const hide = () => setPos(null);
return (
<span
ref={iconRef}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
tabIndex={0}
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-neutral-500 text-[10px] text-neutral-400 cursor-help hover:border-amber-400 hover:text-amber-400 transition align-middle"
>
?
{mounted && pos &&
createPortal(
<div
style={{ position: "fixed", top: pos.top, left: pos.left, width, zIndex: 1000 }}
className="p-3 bg-neutral-900 border border-neutral-700 rounded shadow-xl text-xs text-neutral-200 pointer-events-none"
>
{children}
</div>,
document.body
)}
</span>
);
}
@@ -0,0 +1,64 @@
"use client";
import { type ReactNode, useEffect } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
wide?: boolean;
/**
* When true, X + Esc prompt for confirmation before closing.
* Backdrop clicks never close — user must use X.
*/
confirmClose?: boolean;
confirmMessage?: string;
}
export default function Modal({
open,
onClose,
title,
children,
wide,
confirmClose,
confirmMessage = "Discard changes? Any unsaved data will be lost.",
}: ModalProps) {
const requestClose = () => {
if (confirmClose && !window.confirm(confirmMessage)) return;
onClose();
};
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") requestClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, confirmClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div
onClick={(e) => e.stopPropagation()}
className={`bg-neutral-900 border border-neutral-700 rounded-lg shadow-2xl w-full ${wide ? "max-w-4xl" : "max-w-lg"} max-h-[90vh] overflow-y-auto`}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-700">
<h2 className="text-lg font-semibold text-neutral-100">{title}</h2>
<button
onClick={requestClose}
className="text-neutral-400 hover:text-neutral-100 text-2xl leading-none"
aria-label="Close"
>
×
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,77 @@
"use client";
import type { ReactNode } from "react";
interface Item {
id: string;
name: string;
description?: string;
creatorId?: string;
visibility?: string;
ruleset?: string;
}
interface SelectorGridProps<T extends Item> {
items: T[];
selectedId?: string;
onSelect: (item: T) => void;
onAddCustom: () => void;
addLabel: string;
renderMeta?: (item: T) => ReactNode;
}
export default function SelectorGrid<T extends Item>({
items,
selectedId,
onSelect,
onAddCustom,
addLabel,
renderMeta,
}: SelectorGridProps<T>) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{items.map((item) => {
const isSelected = item.id === selectedId;
return (
<button
key={item.id}
onClick={() => onSelect(item)}
className={`text-left p-4 rounded-lg border transition ${
isSelected
? "border-amber-500 bg-amber-500/10"
: "border-neutral-700 bg-neutral-800/60 hover:bg-neutral-800 hover:border-neutral-500"
}`}
>
<div className="flex items-start justify-between gap-2">
<span className="font-semibold text-neutral-100">{item.name}</span>
<div className="flex flex-wrap gap-1">
{item.ruleset && (
<span className="text-[10px] uppercase tracking-wide text-neutral-400 border border-neutral-600 px-1.5 py-0.5 rounded">
{item.ruleset}
</span>
)}
{item.visibility === "private" && (
<span className="text-[10px] uppercase tracking-wide text-amber-400 border border-amber-400/40 px-1.5 py-0.5 rounded">
Custom
</span>
)}
</div>
</div>
{item.description && (
<p className="mt-2 text-sm text-neutral-400 line-clamp-3">{item.description}</p>
)}
{renderMeta && <div className="mt-2 text-xs text-neutral-500">{renderMeta(item)}</div>}
</button>
);
})}
<button
onClick={onAddCustom}
className="p-4 rounded-lg border-2 border-dashed border-neutral-600 text-neutral-400 hover:border-amber-500 hover:text-amber-400 transition flex items-center justify-center gap-2"
>
<span className="text-2xl leading-none">+</span>
<span className="font-medium">{addLabel}</span>
</button>
</div>
);
}
@@ -0,0 +1,196 @@
"use client";
import { useState } from "react";
import { RULESET_LABELS, RULESETS, type CreatureType, type Feature, type Ruleset, type Size, type Species } from "@/types/content";
import { useCreator } from "../CreatorContext";
import { useIsAdmin } from "@/hooks/useIsAdmin";
import AttachedFeatures from "./AttachedFeatures";
import { claimFeaturesForParent } from "@/lib/claimFeatures";
const SIZES: Size[] = ["tiny", "small", "medium", "large", "huge", "gargantuan"];
const CREATURE_TYPES: CreatureType[] = [
"aberration", "beast", "celestial", "construct", "dragon", "elemental",
"fey", "fiend", "giant", "humanoid", "monstrosity", "ooze", "plant", "undead",
];
interface Props {
onSave: (s: Species) => void;
onCancel: () => void;
}
export default function SpeciesBuilder({ onSave, onCancel }: Props) {
const isAdmin = useIsAdmin();
const { features, refreshFeatures } = useCreator();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [sizes, setSizes] = useState<Size[]>(["medium"]);
const [creatureType, setCreatureType] = useState<CreatureType>("humanoid");
const [speed, setSpeed] = useState(30);
const [languagesStr, setLanguagesStr] = useState("Common");
const [featureIds, setFeatureIds] = useState<string[]>([]);
const [visibility, setVisibility] = useState<"private" | "public">("private");
const [ruleset, setRuleset] = useState<Ruleset>("5e");
const [busy, setBusy] = useState(false);
const available = Object.values(features);
const handleFeatureCreated = async (_f: Feature) => {
await refreshFeatures();
};
const submit = async () => {
if (!name.trim()) return;
setBusy(true);
const payload = {
name: name.trim(),
description: description.trim(),
creatureType,
sizes,
speed,
featureIds,
languages: languagesStr.split(",").map((s) => s.trim()).filter(Boolean),
visibility,
ruleset,
};
const res = await fetch("/api/species", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
setBusy(false);
return;
}
const { result } = await res.json();
await claimFeaturesForParent({
parentType: "species",
parentId: (result as Species).id,
featureIds,
featureDict: features,
});
await refreshFeatures();
setBusy(false);
onSave(result as Species);
};
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-neutral-400 mb-1">Name *</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Size(s) pick one or more</label>
<div className="flex flex-wrap gap-2">
{SIZES.map((s) => {
const on = sizes.includes(s);
return (
<label
key={s}
className={`px-2 py-1 rounded border text-xs cursor-pointer capitalize ${on ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}
>
<input
type="checkbox"
className="hidden"
checked={on}
onChange={() =>
setSizes(on ? sizes.filter((x) => x !== s) : [...sizes, s])
}
/>
{s}
</label>
);
})}
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Speed (ft)</label>
<input
type="number"
value={speed}
onChange={(e) => setSpeed(Number(e.target.value) || 0)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Languages (comma-separated)</label>
<input
value={languagesStr}
onChange={(e) => setLanguagesStr(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div className="col-span-2">
<label className="block text-sm text-neutral-400 mb-1">Creature Type</label>
<select
value={creatureType}
onChange={(e) => setCreatureType(e.target.value as CreatureType)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm capitalize"
>
{CREATURE_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-2">Traits &amp; Features</label>
<AttachedFeatures
source="species"
parentType="species"
available={available}
attachedIds={featureIds}
onChange={setFeatureIds}
onFeatureCreated={handleFeatureCreated}
/>
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Visibility:</label>
<select
value={visibility}
onChange={(e) => setVisibility(e.target.value as "private" | "public")}
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm"
>
<option value="private">Private</option>
<option value="public">Public (admin)</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Ruleset:</label>
<select
value={ruleset}
onChange={(e) => setRuleset(e.target.value as Ruleset)}
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm"
>
{RULESETS.map((r) => <option key={r} value={r}>{RULESET_LABELS[r]}</option>)}
</select>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onCancel} className="px-4 py-2 text-sm text-neutral-300 hover:text-neutral-100">Cancel</button>
<button
onClick={submit}
disabled={busy || !name.trim()}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
{busy ? "Saving..." : "Save Species"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { RULESETS, RULESET_LABELS, type Ruleset, type Spell } from "@/types/content";
import { useIsAdmin } from "@/hooks/useIsAdmin";
const SCHOOLS = [
"abjuration", "conjuration", "divination", "enchantment",
"evocation", "illusion", "necromancy", "transmutation",
];
interface Props {
onSave: (s: Spell) => void;
onCancel: () => void;
}
export default function SpellBuilder({ onSave, onCancel }: Props) {
const isAdmin = useIsAdmin();
const [name, setName] = useState("");
const [level, setLevel] = useState(0);
const [school, setSchool] = useState("evocation");
const [castingTime, setCastingTime] = useState("1 action");
const [range, setRange] = useState("30 feet");
const [components, setComponents] = useState("V, S");
const [duration, setDuration] = useState("Instantaneous");
const [description, setDescription] = useState("");
const [ruleset, setRuleset] = useState<Ruleset>("5e");
const [visibility, setVisibility] = useState<"private" | "public">("private");
const [busy, setBusy] = useState(false);
const submit = async () => {
if (!name.trim()) return;
setBusy(true);
const payload = {
name: name.trim(),
level,
school,
castingTime,
range,
components,
duration,
description,
ruleset,
visibility,
};
const res = await fetch("/api/spells", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
setBusy(false);
if (!res.ok) return;
const { result } = await res.json();
onSave(result as Spell);
};
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="block text-sm text-neutral-400 mb-1">Name *</label>
<input value={name} onChange={(e) => setName(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Level (0 = cantrip)</label>
<input type="number" min={0} max={9} value={level} onChange={(e) => setLevel(Math.max(0, Math.min(9, Number(e.target.value) || 0)))} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">School</label>
<select value={school} onChange={(e) => setSchool(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm capitalize">
{SCHOOLS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Casting time</label>
<input value={castingTime} onChange={(e) => setCastingTime(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Range</label>
<input value={range} onChange={(e) => setRange(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Components</label>
<input value={components} onChange={(e) => setComponents(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" placeholder="V, S, M (a pinch of salt)" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Duration</label>
<input value={duration} onChange={(e) => setDuration(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={4} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Visibility:</label>
<select value={visibility} onChange={(e) => setVisibility(e.target.value as "private" | "public")} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
<option value="private">Private</option>
<option value="public">Public (admin)</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Ruleset:</label>
<select value={ruleset} onChange={(e) => setRuleset(e.target.value as Ruleset)} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
{RULESETS.map((r) => <option key={r} value={r}>{RULESET_LABELS[r]}</option>)}
</select>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onCancel} className="px-4 py-2 text-sm text-neutral-300 hover:text-neutral-100">Cancel</button>
<button
onClick={submit}
disabled={busy || !name.trim()}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
{busy ? "Saving..." : "Save Spell"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,180 @@
"use client";
import { useRef } from "react";
interface Row {
level: number;
cantripsKnown: number;
spellsKnown: number;
slots: number[];
}
interface Props {
progression: Row[];
onChange: (rows: Row[]) => void;
}
const COLUMNS = 12; // level, cantrips, spells, slots 1-9
function cellId(r: number, c: number) {
return `sp-cell-${r}-${c}`;
}
export default function SpellProgressionTable({ progression, onChange }: Props) {
const rootRef = useRef<HTMLDivElement>(null);
const patchRow = (idx: number, patch: Partial<Row>) => {
onChange(progression.map((r, k) => (k === idx ? { ...r, ...patch } : r)));
};
const patchSlot = (idx: number, spellLv: number, value: number) => {
const row = progression[idx];
const nextSlots = [...(row.slots ?? [])];
while (nextSlots.length <= spellLv - 1) nextSlots.push(0);
nextSlots[spellLv - 1] = value;
patchRow(idx, { slots: nextSlots });
};
const addRow = () => {
const last = progression[progression.length - 1];
onChange([
...progression,
{ level: (last?.level ?? 0) + 1, cantripsKnown: last?.cantripsKnown ?? 0, spellsKnown: last?.spellsKnown ?? 0, slots: [...(last?.slots ?? [])] },
]);
};
const removeRow = (k: number) => onChange(progression.filter((_, i) => i !== k));
const setCell = (r: number, c: number, raw: string) => {
const v = Number(raw) || 0;
if (c === 0) patchRow(r, { level: v || 1 });
else if (c === 1) patchRow(r, { cantripsKnown: Math.max(0, v) });
else if (c === 2) patchRow(r, { spellsKnown: Math.max(0, v) });
else patchSlot(r, c - 2, Math.max(0, v));
};
const focusCell = (r: number, c: number) => {
const el = rootRef.current?.querySelector<HTMLInputElement>(`#${cellId(r, c)}`);
el?.focus();
el?.select();
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, r: number, c: number) => {
const maxR = progression.length - 1;
if (e.key === "ArrowRight" || (e.key === "Enter" && e.shiftKey === false && e.altKey === false && !e.ctrlKey && !e.metaKey && c < COLUMNS - 1)) {
e.preventDefault();
focusCell(r, Math.min(COLUMNS - 1, c + 1));
} else if (e.key === "ArrowLeft") {
e.preventDefault();
focusCell(r, Math.max(0, c - 1));
} else if (e.key === "ArrowDown" || (e.key === "Enter" && c === COLUMNS - 1)) {
e.preventDefault();
if (r < maxR) focusCell(r + 1, c === COLUMNS - 1 ? 0 : c);
} else if (e.key === "ArrowUp") {
e.preventDefault();
focusCell(Math.max(0, r - 1), c);
}
};
const onPaste = (e: React.ClipboardEvent<HTMLInputElement>, startR: number, startC: number) => {
const text = e.clipboardData.getData("text");
if (!text || (!text.includes("\t") && !text.includes("\n"))) return; // single value: let default happen
e.preventDefault();
const rows = text.replace(/\r/g, "").split("\n").filter((line) => line.length > 0);
const grid = rows.map((line) => line.split("\t"));
const next: Row[] = progression.map((r) => ({ ...r, slots: [...r.slots] }));
for (let dr = 0; dr < grid.length; dr++) {
const targetRow = startR + dr;
while (next.length <= targetRow) {
const last = next[next.length - 1];
next.push({
level: (last?.level ?? 0) + 1,
cantripsKnown: 0,
spellsKnown: 0,
slots: [],
});
}
for (let dc = 0; dc < grid[dr].length; dc++) {
const targetC = startC + dc;
if (targetC >= COLUMNS) break;
const raw = grid[dr][dc].trim();
// Skip empty cells so partial pastes don't wipe existing data
if (raw === "") continue;
const v = Number(raw);
if (Number.isNaN(v)) continue;
const row = next[targetRow];
if (targetC === 0) row.level = v || 1;
else if (targetC === 1) row.cantripsKnown = Math.max(0, v);
else if (targetC === 2) row.spellsKnown = Math.max(0, v);
else {
const spellLv = targetC - 2;
while (row.slots.length <= spellLv - 1) row.slots.push(0);
row.slots[spellLv - 1] = Math.max(0, v);
}
}
}
onChange(next);
};
return (
<div ref={rootRef} className="flex flex-col gap-2 w-full">
<div className="text-xs text-neutral-500">
Tip: paste tab-separated rows (e.g. from Excel or dndbeyond) into any cell. Arrow keys / Enter to navigate.
</div>
<div className="overflow-x-auto">
<table className="text-xs border-collapse w-full">
<thead>
<tr className="text-neutral-500">
<th className="p-1 text-left">Lv</th>
<th className="p-1 text-left">Cantrips</th>
<th className="p-1 text-left">Spells</th>
{[1,2,3,4,5,6,7,8,9].map((sl) => (
<th key={sl} className="p-1 text-center">{sl}</th>
))}
<th className="p-1"></th>
</tr>
</thead>
<tbody>
{progression.map((row, r) => (
<tr key={r} className="border-t border-neutral-800">
{[
row.level,
row.cantripsKnown,
row.spellsKnown,
...Array.from({ length: 9 }, (_, i) => row.slots[i] ?? 0),
].map((val, c) => (
<td key={c} className="p-1">
<input
id={cellId(r, c)}
type="number"
min={c === 0 ? 1 : 0}
max={c === 0 ? 20 : undefined}
value={val}
onChange={(e) => setCell(r, c, e.target.value)}
onKeyDown={(e) => onKeyDown(e, r, c)}
onPaste={(e) => onPaste(e, r, c)}
className={`bg-neutral-800 border border-neutral-700 rounded px-1 py-0.5 ${c >= 3 ? "w-10 text-center" : "w-12"}`}
/>
</td>
))}
<td className="p-1">
<button
type="button"
onClick={() => removeRow(r)}
className="text-red-400 hover:text-red-300 text-xs px-1"
>×</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
onClick={addRow}
className="self-start text-xs text-amber-400 border border-amber-400/40 hover:bg-amber-400/10 rounded px-3 py-1"
>
+ Add level row
</button>
</div>
);
}
@@ -0,0 +1,139 @@
"use client";
import { useState } from "react";
import { RULESETS, RULESET_LABELS, type Feature, type Ruleset, type Subclass } from "@/types/content";
import { useCreator } from "../CreatorContext";
import { useIsAdmin } from "@/hooks/useIsAdmin";
import AttachedFeatures from "./AttachedFeatures";
import { claimFeaturesForParent } from "@/lib/claimFeatures";
interface Props {
classId: string;
className: string;
onSave: (s: Subclass) => void;
onCancel: () => void;
}
export default function SubclassBuilder({ classId, className, onSave, onCancel }: Props) {
const isAdmin = useIsAdmin();
const { features, refreshFeatures } = useCreator();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [featureIds, setFeatureIds] = useState<string[]>([]);
const [visibility, setVisibility] = useState<"private" | "public">("private");
const [ruleset, setRuleset] = useState<Ruleset>("5e");
const [busy, setBusy] = useState(false);
const available = Object.values(features);
const handleFeatureCreated = async (_f: Feature) => {
await refreshFeatures();
};
const submit = async () => {
if (!name.trim()) return;
setBusy(true);
const attachedFeatures = featureIds
.map((id) => features[id])
.filter((f): f is Feature => !!f);
const grouped: Record<number, string[]> = {};
for (const f of attachedFeatures) {
const lv = f.level ?? 1;
(grouped[lv] ||= []).push(f.id);
}
const payload = {
classId,
name: name.trim(),
description: description.trim(),
levelFeatures: Object.entries(grouped).map(([lv, ids]) => ({
level: Number(lv),
featureIds: ids,
})),
visibility,
ruleset,
};
const res = await fetch("/api/subclasses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
setBusy(false);
return;
}
const { result } = await res.json();
await claimFeaturesForParent({
parentType: "subclass",
parentId: (result as Subclass).id,
featureIds,
featureDict: features,
});
await refreshFeatures();
setBusy(false);
onSave(result as Subclass);
};
return (
<div className="space-y-4">
<div className="text-xs text-neutral-500">Parent class: <span className="text-neutral-300">{className}</span></div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Name *</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Thief, Assassin, Champion"
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-2">Subclass Features (set the &quot;level&quot; on each)</label>
<AttachedFeatures
source="subclass"
parentType="subclass"
available={available}
attachedIds={featureIds}
onChange={setFeatureIds}
onFeatureCreated={handleFeatureCreated}
/>
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Visibility:</label>
<select value={visibility} onChange={(e) => setVisibility(e.target.value as "private" | "public")} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
<option value="private">Private</option>
<option value="public">Public (admin)</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Ruleset:</label>
<select value={ruleset} onChange={(e) => setRuleset(e.target.value as Ruleset)} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
{RULESETS.map((r) => <option key={r} value={r}>{RULESET_LABELS[r]}</option>)}
</select>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onCancel} className="px-4 py-2 text-sm text-neutral-300 hover:text-neutral-100">Cancel</button>
<button
onClick={submit}
disabled={busy || !name.trim()}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
{busy ? "Saving..." : "Save Subclass"}
</button>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import Wizard from "./Wizard";
export default async function Page() {
const session = await auth();
if (!session) {
redirect("/login?callbackUrl=/charactercreator");
}
return <Wizard />;
}
@@ -0,0 +1,478 @@
"use client";
import { useMemo, useState } from "react";
import { ABILITIES, Ability, AbilityScores } from "@/types/content";
import { useCreator, AbilityMethod } from "../CreatorContext";
import { useDiceContext } from "@/components/DiceContext";
import { getAbilityModifier } from "@/lib/dndHelpers";
const STANDARD_ARRAY = [15, 14, 13, 12, 10, 8];
const POINT_BUY_COST: Record<number, number> = {
8: 0, 9: 1, 10: 2, 11: 3, 12: 4, 13: 5, 14: 7, 15: 9,
};
const POINT_BUY_BUDGET = 27;
function fmtMod(m: number) { return m >= 0 ? `+${m}` : `${m}`; }
export default function AbilitiesStep() {
const { draft, update, species, classes, backgrounds, features, subclasses } = useCreator();
const dice = useDiceContext();
const setMethod = (m: AbilityMethod) => {
if (m === "standardArray") {
update({ abilityMethod: m, abilities: { strength: 8, dexterity: 8, constitution: 8, intelligence: 8, wisdom: 8, charisma: 8 } });
} else if (m === "pointBuy") {
update({ abilityMethod: m, abilities: { strength: 8, dexterity: 8, constitution: 8, intelligence: 8, wisdom: 8, charisma: 8 } });
} else {
update({ abilityMethod: m });
}
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-100">Ability Scores</h2>
<p className="text-sm text-neutral-400 mt-1">Pick a generation method.</p>
</div>
<div className="flex gap-2 flex-wrap">
{(["standardArray", "pointBuy", "rolled", "manual"] as AbilityMethod[]).map((m) => (
<button
key={m}
onClick={() => setMethod(m)}
className={`px-4 py-2 rounded border text-sm ${draft.abilityMethod === m ? "bg-amber-600/20 border-amber-500 text-amber-300" : "border-neutral-700 text-neutral-300"}`}
>
{m === "standardArray" ? "Standard Array" : m === "pointBuy" ? "Point Buy" : m === "rolled" ? "Rolled (4d6 drop 1)" : "Manual"}
</button>
))}
</div>
{draft.abilityMethod === "standardArray" && <StandardArray />}
{draft.abilityMethod === "pointBuy" && <PointBuy />}
{draft.abilityMethod === "rolled" && <Rolled dice={dice} />}
{draft.abilityMethod === "manual" && <Manual />}
<AbilityImprovementSection
species={species}
classes={classes}
backgrounds={backgrounds}
subclasses={subclasses}
features={features}
/>
<ScoreSummary abilities={draft.abilities} />
</div>
);
}
interface AsiSlot {
featureId: string;
featureName: string;
points: number;
maxPerAbility: number;
cap?: number;
}
function AbilityImprovementSection({
species,
classes,
backgrounds,
subclasses,
features,
}: {
species: import("@/types/content").Species[];
classes: import("@/types/content").ClassDef[];
backgrounds: import("@/types/content").Background[];
subclasses: import("@/types/content").Subclass[];
features: Record<string, import("@/types/content").Feature>;
}) {
const { draft, update } = useCreator();
const level = draft.classes[0]?.level ?? 1;
const chosenClass = classes.find((c) => c.id === draft.classes[0]?.classId);
const chosenSpecies = species.find((s) => s.id === draft.speciesId);
const chosenBg = backgrounds.find((b) => b.id === draft.backgroundId);
const chosenSubclassId = draft.classes[0]?.subclassId ??
(chosenClass ? (() => {
for (const lf of chosenClass.levelFeatures ?? []) {
if (lf.level > level) continue;
for (const fid of lf.featureIds) {
const f = features[fid];
if (f?.effects?.some((e) => e.target.kind === "grantSubclassChoice")) {
const pick = draft.featureChoices[fid]?.[0];
if (pick) return pick;
}
}
}
return undefined;
})() : undefined);
const chosenSubclass = subclasses.find((s) => s.id === chosenSubclassId);
const activeFeatureIds = useMemo(() => {
const ids: string[] = [];
ids.push(...(chosenSpecies?.featureIds ?? []));
ids.push(...(chosenBg?.featureIds ?? []));
for (const lf of chosenClass?.levelFeatures ?? []) {
if (lf.level <= level) ids.push(...lf.featureIds);
}
for (const lf of chosenSubclass?.levelFeatures ?? []) {
if (lf.level <= level) ids.push(...lf.featureIds);
}
return ids;
}, [chosenSpecies, chosenBg, chosenClass, chosenSubclass, level]);
const asiSlots: AsiSlot[] = useMemo(() => {
const out: AsiSlot[] = [];
for (const fid of activeFeatureIds) {
const f = features[fid];
if (!f) continue;
for (const eff of f.effects ?? []) {
if (eff.target.kind === "abilityScoreChoice" && (eff.level ?? 1) <= level) {
out.push({
featureId: f.id,
featureName: f.name,
points: eff.target.points,
maxPerAbility: eff.target.maxPerAbility,
cap: eff.target.cap,
});
}
}
}
return out;
}, [activeFeatureIds, features, level]);
if (asiSlots.length === 0) return null;
const applyPatch = (featureId: string, next: string[]) => {
update({ featureChoices: { ...draft.featureChoices, [featureId]: next } });
};
return (
<div className="space-y-4">
{asiSlots.map((slot) => {
const picks = (draft.featureChoices[slot.featureId] ?? []) as Ability[];
const counts: Partial<Record<Ability, number>> = {};
for (const a of picks) counts[a] = (counts[a] ?? 0) + 1;
const remaining = slot.points - picks.length;
// Baseline = current draft ability + fixed bonuses from other active features
// + picks from OTHER asi slots. Excludes this slot's own picks so the delta shown
// for this slot lives on top of that baseline.
const baseline: Record<Ability, number> = { ...draft.abilities };
for (const fid of activeFeatureIds) {
const f = features[fid];
if (!f) continue;
for (const eff of f.effects ?? []) {
if ((eff.level ?? 1) > level) continue;
if (eff.target.kind === "ability") {
const delta =
eff.operation === "decrease" ? -(eff.value ?? 0) : eff.value ?? 0;
if (eff.operation === "set" && eff.value !== undefined) {
baseline[eff.target.ability] = eff.value;
} else {
baseline[eff.target.ability] += delta;
}
} else if (eff.target.kind === "abilityScoreChoice" && f.id !== slot.featureId) {
const otherPicks = (draft.featureChoices[f.id] ?? []) as Ability[];
const otherCounts: Partial<Record<Ability, number>> = {};
for (const a of otherPicks) otherCounts[a] = (otherCounts[a] ?? 0) + 1;
for (const a of ABILITIES) {
baseline[a] += Math.min(otherCounts[a] ?? 0, eff.target.maxPerAbility);
}
}
}
}
const inc = (a: Ability) => {
if (remaining <= 0) return;
if ((counts[a] ?? 0) >= slot.maxPerAbility) return;
if (slot.cap !== undefined && baseline[a] + (counts[a] ?? 0) + 1 > slot.cap) return;
applyPatch(slot.featureId, [...picks, a]);
};
const dec = (a: Ability) => {
const idx = picks.lastIndexOf(a);
if (idx < 0) return;
const next = [...picks];
next.splice(idx, 1);
applyPatch(slot.featureId, next);
};
return (
<div key={slot.featureId} className="p-4 border border-emerald-500/40 bg-emerald-500/5 rounded">
<div className="text-sm font-semibold text-emerald-300 mb-1">
{slot.featureName} distribute {slot.points} points (max {slot.maxPerAbility} per ability{slot.cap ? `, cap ${slot.cap}` : ""})
<span className="ml-2 text-xs text-neutral-500">({picks.length}/{slot.points})</span>
</div>
<div className="grid grid-cols-3 md:grid-cols-6 gap-2 mt-3">
{ABILITIES.map((a) => {
const current = counts[a] ?? 0;
const wouldBe = baseline[a] + current;
const cappedOut = slot.cap !== undefined && wouldBe >= slot.cap;
return (
<div key={a} className="p-2 border border-neutral-700 rounded text-center">
<div className="text-xs uppercase text-neutral-500">{a.slice(0, 3)}</div>
<div className="flex items-center justify-center gap-1 mt-1">
<button type="button" onClick={() => dec(a)} className="w-6 h-6 rounded bg-neutral-800 hover:bg-neutral-700"></button>
<span className="text-lg font-bold w-6 text-emerald-300">+{current}</span>
<button
type="button"
onClick={() => inc(a)}
disabled={cappedOut || remaining <= 0 || current >= slot.maxPerAbility}
className="w-6 h-6 rounded bg-neutral-800 hover:bg-neutral-700 disabled:opacity-30 disabled:cursor-not-allowed"
>+</button>
</div>
<div className={`text-[10px] mt-1 ${cappedOut ? "text-red-400" : "text-neutral-500"}`}>
{wouldBe}{slot.cap !== undefined ? `/${slot.cap}` : ""}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
function ScoreSummary({ abilities }: { abilities: AbilityScores }) {
return (
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
{ABILITIES.map((a) => (
<div key={a} className="p-3 border border-neutral-700 rounded bg-neutral-900 text-center">
<div className="text-xs uppercase text-neutral-500">{a.slice(0, 3)}</div>
<div className="text-2xl font-bold text-neutral-100">{abilities[a]}</div>
<div className="text-sm text-amber-400">{fmtMod(getAbilityModifier(abilities[a]))}</div>
</div>
))}
</div>
);
}
function Manual() {
const { draft, update } = useCreator();
const set = (a: Ability, v: number) =>
update({ abilities: { ...draft.abilities, [a]: v } });
return (
<div className="grid grid-cols-3 md:grid-cols-6 gap-3">
{ABILITIES.map((a) => (
<div key={a}>
<label className="block text-xs uppercase text-neutral-400 mb-1">{a}</label>
<input
type="number"
value={draft.abilities[a]}
onChange={(e) => set(a, Number(e.target.value) || 0)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-2 text-center text-lg"
/>
</div>
))}
</div>
);
}
function StandardArray() {
const { draft, update } = useCreator();
const assignments = ABILITIES.map((a) => draft.abilities[a]);
const remaining = [...STANDARD_ARRAY];
for (const v of assignments) {
const idx = remaining.indexOf(v);
if (idx >= 0) remaining.splice(idx, 1);
}
const setAbility = (a: Ability, value: number) => {
update({ abilities: { ...draft.abilities, [a]: value } });
};
return (
<div className="space-y-3">
<p className="text-sm text-neutral-400">
Pool: <span className="font-mono text-neutral-200">{STANDARD_ARRAY.join(", ")}</span>
</p>
<div className="grid grid-cols-3 md:grid-cols-6 gap-3">
{ABILITIES.map((a) => {
const current = draft.abilities[a];
const options = [8, ...new Set([...STANDARD_ARRAY, current])].sort((x, y) => y - x);
return (
<div key={a}>
<label className="block text-xs uppercase text-neutral-400 mb-1">{a}</label>
<select
value={current}
onChange={(e) => setAbility(a, Number(e.target.value))}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-2 text-center text-lg"
>
{options.map((v) => {
const used = assignments.filter((x) => x === v).length;
const inPool = STANDARD_ARRAY.filter((x) => x === v).length;
const disabled = v !== current && v !== 8 && used >= inPool;
return (
<option key={v} value={v} disabled={disabled}>{v}</option>
);
})}
</select>
</div>
);
})}
</div>
<p className="text-xs text-neutral-500">Unassigned pool: {remaining.length > 0 ? remaining.join(", ") : "empty"}</p>
</div>
);
}
function PointBuy() {
const { draft, update } = useCreator();
const spent = useMemo(
() => ABILITIES.reduce((sum, a) => sum + (POINT_BUY_COST[draft.abilities[a]] ?? 0), 0),
[draft.abilities]
);
const remaining = POINT_BUY_BUDGET - spent;
const adjust = (a: Ability, delta: number) => {
const next = draft.abilities[a] + delta;
if (next < 8 || next > 15) return;
const nextCost = POINT_BUY_COST[next];
const currentCost = POINT_BUY_COST[draft.abilities[a]];
if (POINT_BUY_BUDGET - (spent - currentCost + nextCost) < 0) return;
update({ abilities: { ...draft.abilities, [a]: next } });
};
return (
<div className="space-y-3">
<div className="text-sm">
Points remaining: <span className={`font-bold ${remaining < 0 ? "text-red-400" : "text-amber-400"}`}>{remaining}</span> / {POINT_BUY_BUDGET}
</div>
<div className="grid grid-cols-3 md:grid-cols-6 gap-3">
{ABILITIES.map((a) => (
<div key={a} className="p-3 border border-neutral-700 rounded text-center">
<div className="text-xs uppercase text-neutral-400">{a}</div>
<div className="flex items-center justify-center gap-2 mt-2">
<button onClick={() => adjust(a, -1)} className="w-7 h-7 rounded bg-neutral-800 hover:bg-neutral-700 text-lg"></button>
<span className="text-xl font-bold w-8">{draft.abilities[a]}</span>
<button onClick={() => adjust(a, 1)} className="w-7 h-7 rounded bg-neutral-800 hover:bg-neutral-700 text-lg">+</button>
</div>
<div className="text-xs text-neutral-500 mt-1">cost {POINT_BUY_COST[draft.abilities[a]] ?? "—"}</div>
</div>
))}
</div>
</div>
);
}
function Rolled({ dice }: { dice: ReturnType<typeof useDiceContext> }) {
const { draft, update } = useCreator();
const [pool, setPool] = useState<number[]>([]);
const [assignments, setAssignments] = useState<(Ability | null)[]>(
() => Array.from({ length: 6 }, () => null)
);
const [rolling, setRolling] = useState(false);
const rollOne = async (): Promise<number> => {
const roller = dice.rollerRef.current;
if (roller) {
try {
const outcome = await roller.roll({ 6: 4 }, "Ability roll (4d6 drop lowest)");
const rolls = outcome.roll.map((r) => r.value).sort((a, b) => b - a);
return rolls[0] + rolls[1] + rolls[2];
} catch {
/* fall through */
}
}
const rolls = Array.from({ length: 4 }, () => 1 + Math.floor(Math.random() * 6)).sort((a, b) => b - a);
return rolls[0] + rolls[1] + rolls[2];
};
const rollNext = async () => {
if (pool.length >= 6) return;
setRolling(true);
const v = await rollOne();
setPool([...pool, v]);
setRolling(false);
};
const setSlot = (i: number, v: number) => {
const next = [...pool];
next[i] = v;
setPool(next);
};
const setAssignment = (i: number, a: Ability | null) => {
const next = [...assignments];
next[i] = a;
setAssignments(next);
};
const applyAssignments = () => {
const nextAbilities = { ...draft.abilities };
for (let i = 0; i < 6; i++) {
const a = assignments[i];
const v = pool[i];
if (a && typeof v === "number" && v > 0) nextAbilities[a] = v;
}
update({ abilities: nextAbilities });
};
const hasSomethingToApply = assignments.some((a, i) => a && (pool[i] ?? 0) > 0);
return (
<div className="space-y-4">
<div className="flex gap-2 flex-wrap">
<button
onClick={rollNext}
disabled={rolling || pool.length >= 6}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
{rolling ? "Rolling..." : pool.length >= 6 ? "Pool full" : `Roll next (${pool.length}/6)`}
</button>
<button
onClick={() => setPool([])}
className="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200"
>
Clear
</button>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-2">
Pool (physical-dice players: enter values manually). Pick an ability under each slot to assign it.
</label>
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex flex-col gap-1">
<input
type="number"
min={3}
max={18}
value={pool[i] ?? ""}
onChange={(e) => setSlot(i, Number(e.target.value) || 0)}
placeholder="—"
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-2 text-center text-lg"
/>
<select
value={assignments[i] ?? ""}
onChange={(e) => setAssignment(i, (e.target.value || null) as Ability | null)}
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm capitalize"
>
<option value="">---</option>
{ABILITIES.map((a) => {
const takenByOther = assignments.some((x, k) => k !== i && x === a);
return (
<option key={a} value={a} disabled={takenByOther}>{a}</option>
);
})}
</select>
</div>
))}
</div>
</div>
<div>
<button
type="button"
onClick={applyAssignments}
disabled={!hasSomethingToApply}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
Apply changes
</button>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
"use client";
import { useEffect, useState } from "react";
import { useCreator } from "../CreatorContext";
import SelectorGrid from "../components/SelectorGrid";
import Modal from "../components/Modal";
import BackgroundBuilder from "../components/BackgroundBuilder";
export default function BackgroundStep() {
const { backgrounds, refreshBackgrounds, draft, update } = useCreator();
const [builderOpen, setBuilderOpen] = useState(false);
useEffect(() => { refreshBackgrounds(); }, [refreshBackgrounds]);
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-100">Choose a Background</h2>
<p className="text-sm text-neutral-400 mt-1">Your character&apos;s life before adventuring.</p>
</div>
<SelectorGrid
items={backgrounds}
selectedId={draft.backgroundId}
onSelect={(b) => update({ backgroundId: b.id })}
onAddCustom={() => setBuilderOpen(true)}
addLabel="Add Background"
renderMeta={(b) => `${b.skillProficiencies?.length ?? 0} skills`}
/>
<Modal open={builderOpen} onClose={() => setBuilderOpen(false)} title="Create Custom Background" wide confirmClose>
<BackgroundBuilder
onCancel={() => setBuilderOpen(false)}
onSave={async (b) => {
await refreshBackgrounds();
update({ backgroundId: b.id });
setBuilderOpen(false);
}}
/>
</Modal>
</div>
);
}
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useState } from "react";
import { useCreator } from "../CreatorContext";
import SelectorGrid from "../components/SelectorGrid";
import Modal from "../components/Modal";
import ClassBuilder from "../components/ClassBuilder";
export default function ClassStep() {
const { classes, refreshClasses, draft, update } = useCreator();
const [builderOpen, setBuilderOpen] = useState(false);
useEffect(() => { refreshClasses(); }, [refreshClasses]);
const selectedClassId = draft.classes[0]?.classId;
const selectClass = (classId: string) => {
update({
classes: [{ classId, level: draft.classes[0]?.level ?? 1 }],
});
};
const setLevel = (level: number) => {
if (!draft.classes[0]) return;
update({ classes: [{ ...draft.classes[0], level }] });
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-100">Choose a Class</h2>
<p className="text-sm text-neutral-400 mt-1">Multi-classing planned for later. Pick one for now.</p>
</div>
{selectedClassId && (
<div className="flex items-center gap-3 p-3 bg-neutral-800/60 rounded border border-neutral-700">
<label className="text-sm text-neutral-300">Starting level:</label>
<input
type="number"
min={1}
max={20}
value={draft.classes[0]?.level ?? 1}
onChange={(e) => setLevel(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
className="w-20 bg-neutral-950 border border-neutral-700 rounded px-2 py-1 text-sm"
/>
</div>
)}
<SelectorGrid
items={classes}
selectedId={selectedClassId}
onSelect={(c) => selectClass(c.id)}
onAddCustom={() => setBuilderOpen(true)}
addLabel="Add Class"
renderMeta={(c) => `d${c.hitDice?.sides ?? "?"} hit die`}
/>
<Modal open={builderOpen} onClose={() => setBuilderOpen(false)} title="Create Custom Class" wide confirmClose>
<ClassBuilder
onCancel={() => setBuilderOpen(false)}
onSave={async (c) => {
await refreshClasses();
selectClass(c.id);
setBuilderOpen(false);
}}
/>
</Modal>
</div>
);
}
@@ -0,0 +1,264 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Alignment, Character, Feature, RollMethod, Skill } from "@/types/content";
import { useCreator } from "../CreatorContext";
import { collectAppliedFeatures } from "@/lib/applyFeatures";
import { getAbilityModifier } from "@/lib/dndHelpers";
const ALIGNMENTS: { value: Alignment; label: string }[] = [
{ value: "lawfulGood", label: "Lawful Good" },
{ value: "neutralGood", label: "Neutral Good" },
{ value: "chaoticGood", label: "Chaotic Good" },
{ value: "lawfulNeutral", label: "Lawful Neutral" },
{ value: "trueNeutral", label: "True Neutral" },
{ value: "chaoticNeutral", label: "Chaotic Neutral" },
{ value: "lawfulEvil", label: "Lawful Evil" },
{ value: "neutralEvil", label: "Neutral Evil" },
{ value: "chaoticEvil", label: "Chaotic Evil" },
];
export default function DetailsStep() {
const router = useRouter();
const { draft, update, species, classes, backgrounds, features, subclasses, activeRulesets } = useCreator();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const chosenClass = classes.find((c) => c.id === draft.classes[0]?.classId);
const chosenSpecies = species.find((s) => s.id === draft.speciesId);
const chosenBg = backgrounds.find((b) => b.id === draft.backgroundId);
const level = draft.classes[0]?.level ?? 1;
const conMod = getAbilityModifier(draft.abilities.constitution);
const hpMax = useMemo(() => {
if (!chosenClass) return 0;
const sides = chosenClass.hitDice.sides;
const avgPerLevel = Math.floor(sides / 2) + 1;
const first = sides + conMod;
const rest = (level - 1) * (avgPerLevel + conMod);
if (draft.hpMethod === "average") return first + rest;
return first + rest;
}, [chosenClass, level, conMod, draft.hpMethod]);
const canSave =
!!draft.name.trim() &&
!!draft.speciesId &&
!!draft.classes[0]?.classId;
const buildCharacter = (): Partial<Character> => {
const classFeatureIds = chosenClass
? (chosenClass.levelFeatures ?? [])
.filter((lf) => lf.level <= level)
.flatMap((lf) => lf.featureIds)
: [];
// Detect subclass picks: any featureChoice whose key is a class-feature id with a
// grantSubclassChoice effect. Value = [subclassId].
const pickedSubclass: { subclassId: string } | null = (() => {
for (const fid of classFeatureIds) {
const f = features[fid];
if (!f) continue;
const grantsSubclass = f.effects?.some((e) => e.target.kind === "grantSubclassChoice");
if (!grantsSubclass) continue;
const pick = draft.featureChoices[fid]?.[0];
if (pick) return { subclassId: pick };
}
return null;
})();
const subclass = pickedSubclass ? subclasses.find((s) => s.id === pickedSubclass.subclassId) : undefined;
const subclassFeatureIds = subclass
? (subclass.levelFeatures ?? [])
.filter((lf) => lf.level <= level)
.flatMap((lf) => lf.featureIds)
: [];
const combinedClassFeatureIds = [...classFeatureIds, ...subclassFeatureIds];
const classLevels = chosenClass
? [{
classId: chosenClass.id,
level,
featureIds: combinedClassFeatureIds,
}]
: [];
const applied = collectAppliedFeatures({
speciesFeatureIds: chosenSpecies?.featureIds ?? [],
backgroundFeatureIds: chosenBg?.featureIds ?? [],
classLevels,
speciesId: chosenSpecies?.id ?? "",
backgroundId: chosenBg?.id,
});
const speciesFeatures = (chosenSpecies?.featureIds ?? [])
.map((id) => features[id])
.filter((f): f is Feature => !!f);
const bgFeatures = (chosenBg?.featureIds ?? [])
.map((id) => features[id])
.filter((f): f is Feature => !!f);
const langsFromSpecies = chosenSpecies?.languages ?? [];
const langsFromBg = chosenBg?.languages ?? [];
return {
name: draft.name.trim(),
alignment: draft.alignment,
speciesId: draft.speciesId!,
backgroundId: draft.backgroundId,
classes: chosenClass
? [{ classId: chosenClass.id, level, ...(pickedSubclass ? { subclassId: pickedSubclass.subclassId } : {}) }]
: [],
abilities: draft.abilities,
abilityMethod: draft.abilityMethod,
savingThrowProficiencies: chosenClass?.savingThrowProficiencies ?? [],
skillProficiencies: [
...(chosenBg?.skillProficiencies ?? []),
...draft.skillProficiencies,
...Object.entries(draft.featureChoices ?? {})
.filter(([fid]) => {
const f = features[fid];
return f?.effects?.some((e) => e.target.kind === "skillProficiencyChoice");
})
.flatMap(([, vals]) => vals as Skill[]),
],
featureChoices: draft.featureChoices,
skillExpertise: [],
proficiencies: {
armor: chosenClass?.armorProficiencies ?? [],
weapons: chosenClass?.weaponProficiencies ?? [],
tools: [
...(chosenClass?.toolProficiencies ?? []),
...(chosenBg?.toolProficiencies ?? []),
],
languages: [...new Set([...langsFromSpecies, ...langsFromBg])],
},
feats: [],
appliedFeatures: applied,
hp: {
method: draft.hpMethod,
max: hpMax,
current: hpMax,
history: [],
},
inventory: [],
equipped: { weapons: [] },
magic: (() => {
const spellsByLevel: Record<string, string[]> = {};
const slots: Record<string, [number, number]> = {};
for (const app of applied) {
const f = features[app.featureId];
if (!f) continue;
const picks = (draft.featureChoices[f.id] ?? []) as string[];
for (const raw of picks) {
let spellId: string | null = null;
let bucketKey = "";
if (raw.startsWith("cantrip:")) {
spellId = raw.slice(8);
bucketKey = "0";
} else if (raw.startsWith("spell:")) {
spellId = raw.slice(6);
bucketKey = "spell";
}
if (!spellId) continue;
(spellsByLevel[bucketKey] ||= []).push(spellId);
}
for (const eff of f.effects ?? []) {
if (eff.target.kind !== "grantSpellcasting") continue;
if ((eff.level ?? 1) > level) continue;
const row = eff.target.progression
.filter((r) => r.level <= level)
.sort((a, b) => b.level - a.level)[0];
if (!row) continue;
row.slots.forEach((n, i) => {
if (n > 0) slots[String(i + 1)] = [0, n];
});
}
}
return { spells: spellsByLevel, slots };
})(),
contentSources: [
...speciesFeatures.map((f) => f.id),
...bgFeatures.map((f) => f.id),
],
rulesets: activeRulesets,
};
};
const save = async () => {
if (!canSave) return;
setSaving(true);
setError(null);
const res = await fetch("/api/character", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(buildCharacter()),
});
setSaving(false);
if (!res.ok) {
const { error: err } = await res.json().catch(() => ({ error: "Save failed" }));
setError(err ?? "Save failed");
return;
}
router.push("/characters");
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-100">Character Details</h2>
<p className="text-sm text-neutral-400 mt-1">Finalize name, alignment, and HP method.</p>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Character name *</label>
<input
value={draft.name}
onChange={(e) => update({ name: e.target.value })}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-lg"
placeholder="e.g. Thorgar the Bold"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Alignment</label>
<select
value={draft.alignment ?? ""}
onChange={(e) => update({ alignment: (e.target.value || undefined) as Alignment | undefined })}
className="bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
>
<option value=""> none </option>
{ALIGNMENTS.map((a) => <option key={a.value} value={a.value}>{a.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">HP method</label>
<div className="flex gap-2">
{(["average", "rolled"] as RollMethod[]).map((m) => (
<button
key={m}
onClick={() => update({ hpMethod: m })}
className={`px-3 py-2 text-sm rounded border ${draft.hpMethod === m ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}
>
{m === "average" ? "Take average" : "Rolled (manual for now)"}
</button>
))}
</div>
<p className="text-xs text-neutral-500 mt-1">Estimated max HP: <span className="text-neutral-200">{hpMax}</span></p>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex justify-end">
<button
onClick={save}
disabled={!canSave || saving}
className="px-6 py-3 text-base bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-bold"
>
{saving ? "Saving..." : "Create Character"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,310 @@
"use client";
import { useMemo } from "react";
import { Skill, SKILL_ABILITY } from "@/types/content";
import type { Effect } from "@/types/core";
import { useCreator } from "../CreatorContext";
import { getAbilityModifier } from "@/lib/dndHelpers";
const SKILLS = Object.keys(SKILL_ABILITY) as Skill[];
interface SkillChoice {
featureId: string;
featureName: string;
count: number;
from: Skill[];
}
interface ExpertiseChoice {
featureId: string;
featureName: string;
count: number;
skillPool: Skill[] | "characterSkills";
toolPool: string[];
}
export default function SkillsStep() {
const { draft, update, classes, backgrounds, species, features } = useCreator();
const selectedClass = useMemo(
() => classes.find((c) => c.id === draft.classes[0]?.classId),
[classes, draft.classes]
);
const selectedBackground = useMemo(
() => backgrounds.find((b) => b.id === draft.backgroundId),
[backgrounds, draft.backgroundId]
);
const selectedSpecies = useMemo(
() => species.find((s) => s.id === draft.speciesId),
[species, draft.speciesId]
);
const level = draft.classes[0]?.level ?? 1;
const attachedFeatureIds = useMemo(() => {
const ids: string[] = [];
ids.push(...(selectedSpecies?.featureIds ?? []));
ids.push(...(selectedBackground?.featureIds ?? []));
for (const lf of selectedClass?.levelFeatures ?? []) {
if (lf.level <= level) ids.push(...lf.featureIds);
}
return ids;
}, [selectedSpecies, selectedBackground, selectedClass, level]);
const skillChoices: SkillChoice[] = useMemo(() => {
const out: SkillChoice[] = [];
for (const fid of attachedFeatureIds) {
const f = features[fid];
if (!f) continue;
for (const eff of f.effects ?? []) {
if (eff.target.kind === "skillProficiencyChoice") {
out.push({
featureId: f.id,
featureName: f.name,
count: eff.target.count,
from: eff.target.from,
});
}
}
}
return out;
}, [attachedFeatureIds, features]);
const expertiseChoices: ExpertiseChoice[] = useMemo(() => {
const out: ExpertiseChoice[] = [];
for (const fid of attachedFeatureIds) {
const f = features[fid];
if (!f) continue;
for (const eff of f.effects ?? []) {
if (eff.target.kind === "expertiseChoice") {
out.push({
featureId: f.id,
featureName: f.name,
count: eff.target.count,
skillPool: eff.target.skillPool,
toolPool: eff.target.toolPool,
});
}
}
}
return out;
}, [attachedFeatureIds, features]);
const classSkillOptions = selectedClass?.skillProficiencyOptions?.from ?? [];
const classSkillCount = selectedClass?.skillProficiencyOptions?.count ?? 0;
const backgroundSkills: Skill[] = selectedBackground?.skillProficiencies ?? [];
const featureChosenSkills: Skill[] = useMemo(
() =>
skillChoices.flatMap((c) => (draft.featureChoices[c.featureId] ?? []) as Skill[]),
[skillChoices, draft.featureChoices]
);
const chosen = draft.skillProficiencies;
const chosenFromClass = chosen.filter(
(s) => classSkillOptions.includes(s) && !backgroundSkills.includes(s)
);
const toggleClassSkill = (s: Skill) => {
if (backgroundSkills.includes(s) || featureChosenSkills.includes(s)) return;
if (!classSkillOptions.includes(s)) return;
if (chosen.includes(s)) {
update({ skillProficiencies: chosen.filter((x) => x !== s) });
} else {
if (chosenFromClass.length >= classSkillCount) return;
update({ skillProficiencies: [...chosen, s] });
}
};
const toggleFeatureChoice = (choice: SkillChoice, s: Skill) => {
const current = (draft.featureChoices[choice.featureId] ?? []) as Skill[];
let next: Skill[];
if (current.includes(s)) {
next = current.filter((x) => x !== s);
} else {
if (current.length >= choice.count) return;
next = [...current, s];
}
update({
featureChoices: { ...draft.featureChoices, [choice.featureId]: next },
});
};
const isEffectivelyChosen = (s: Skill) =>
chosen.includes(s) || backgroundSkills.includes(s) || featureChosenSkills.includes(s);
const allCharacterSkills: Skill[] = useMemo(
() => Array.from(new Set([...chosen, ...backgroundSkills, ...featureChosenSkills])),
[chosen, backgroundSkills, featureChosenSkills]
);
const toggleExpertise = (choice: ExpertiseChoice, key: string) => {
const current = (draft.featureChoices[choice.featureId] ?? []) as string[];
let next: string[];
if (current.includes(key)) {
next = current.filter((x) => x !== key);
} else {
if (current.length >= choice.count) return;
next = [...current, key];
}
update({
featureChoices: { ...draft.featureChoices, [choice.featureId]: next },
});
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-100">Skill Proficiencies</h2>
<p className="text-sm text-neutral-400 mt-1">
Class allows {classSkillCount} choice(s). Background + features grant more.
</p>
<p className="text-xs text-neutral-500 mt-1">
Class chosen: {chosenFromClass.length}/{classSkillCount}
</p>
</div>
{skillChoices.map((choice) => {
const picked = (draft.featureChoices[choice.featureId] ?? []) as Skill[];
return (
<div key={choice.featureId} className="p-4 border border-amber-500/40 bg-amber-500/5 rounded">
<div className="text-sm font-semibold text-amber-300 mb-1">
{choice.featureName} pick {choice.count}
<span className="ml-2 text-xs text-neutral-500">({picked.length}/{choice.count})</span>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{choice.from.map((s) => {
const on = picked.includes(s);
const conflictOther =
!on && (chosen.includes(s) || backgroundSkills.includes(s) || featureChosenSkills.includes(s));
const disabled = conflictOther || (!on && picked.length >= choice.count);
return (
<button
key={s}
type="button"
onClick={() => toggleFeatureChoice(choice, s)}
disabled={disabled}
className={`px-2 py-1 text-xs rounded border capitalize transition ${
on
? "bg-amber-500/30 border-amber-400"
: "border-neutral-700 hover:border-neutral-500"
} ${disabled ? "opacity-40 cursor-not-allowed" : ""}`}
>
{s}
</button>
);
})}
</div>
</div>
);
})}
{expertiseChoices.map((choice) => {
const picked = (draft.featureChoices[choice.featureId] ?? []) as string[];
const skillOptions: Skill[] =
choice.skillPool === "characterSkills" ? allCharacterSkills : (choice.skillPool as Skill[]);
return (
<div key={choice.featureId} className="p-4 border border-purple-500/40 bg-purple-500/5 rounded">
<div className="text-sm font-semibold text-purple-300 mb-1">
{choice.featureName} Expertise, pick {choice.count}
<span className="ml-2 text-xs text-neutral-500">({picked.length}/{choice.count})</span>
</div>
{choice.skillPool === "characterSkills" && skillOptions.length === 0 && (
<p className="text-xs text-amber-400 mt-1">Pick skill proficiencies below first.</p>
)}
<div className="mt-2 space-y-2">
{skillOptions.length > 0 && (
<div>
<div className="text-xs text-neutral-500 mb-1">Skills</div>
<div className="flex flex-wrap gap-2">
{skillOptions.map((s) => {
const key = `skill:${s}`;
const on = picked.includes(key);
const disabled = !on && picked.length >= choice.count;
return (
<button
key={key}
type="button"
onClick={() => toggleExpertise(choice, key)}
disabled={disabled}
className={`px-2 py-1 text-xs rounded border capitalize transition ${
on ? "bg-purple-500/30 border-purple-400" : "border-neutral-700 hover:border-neutral-500"
} ${disabled ? "opacity-40 cursor-not-allowed" : ""}`}
>
{s}
</button>
);
})}
</div>
</div>
)}
{choice.toolPool.length > 0 && (
<div>
<div className="text-xs text-neutral-500 mb-1">Tools</div>
<div className="flex flex-wrap gap-2">
{choice.toolPool.map((tool) => {
const key = `tool:${tool}`;
const on = picked.includes(key);
const disabled = !on && picked.length >= choice.count;
return (
<button
key={key}
type="button"
onClick={() => toggleExpertise(choice, key)}
disabled={disabled}
className={`px-2 py-1 text-xs rounded border transition ${
on ? "bg-purple-500/30 border-purple-400" : "border-neutral-700 hover:border-neutral-500"
} ${disabled ? "opacity-40 cursor-not-allowed" : ""}`}
>
{tool}
</button>
);
})}
</div>
</div>
)}
</div>
</div>
);
})}
<div>
<div className="text-sm text-neutral-400 mb-2">
Class starting skills pick {classSkillCount} from the class pool ({chosenFromClass.length}/{classSkillCount}). Non-pool skills shown for reference.
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{SKILLS.map((s) => {
const fromBg = backgroundSkills.includes(s);
const fromFeature = featureChosenSkills.includes(s);
const inClassPool = classSkillOptions.includes(s);
const active = isEffectivelyChosen(s);
const disabled = fromBg || fromFeature || !inClassPool;
const mod = getAbilityModifier(draft.abilities[SKILL_ABILITY[s]]);
return (
<button
key={s}
onClick={() => toggleClassSkill(s)}
disabled={disabled}
className={`text-left p-3 rounded border text-sm transition ${
active
? "bg-amber-500/10 border-amber-500"
: "border-neutral-700 hover:border-neutral-500"
} ${disabled ? "opacity-80 cursor-not-allowed" : ""}`}
>
<div className="flex items-center justify-between">
<span className="font-medium capitalize">{s.replace(/([A-Z])/g, " $1")}</span>
<span className="text-xs text-neutral-500">{SKILL_ABILITY[s].slice(0, 3)}</span>
</div>
<div className="text-xs text-neutral-400 mt-1">
base {mod >= 0 ? `+${mod}` : mod}
{fromBg && <span className="ml-2 text-amber-400">background</span>}
{fromFeature && <span className="ml-2 text-amber-400">feature</span>}
{!fromBg && !fromFeature && inClassPool && <span className="ml-2 text-blue-400">class option</span>}
</div>
</button>
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
"use client";
import { useEffect, useState } from "react";
import { useCreator } from "../CreatorContext";
import SelectorGrid from "../components/SelectorGrid";
import Modal from "../components/Modal";
import SpeciesBuilder from "../components/SpeciesBuilder";
export default function SpeciesStep() {
const { species, refreshSpecies, draft, update } = useCreator();
const [builderOpen, setBuilderOpen] = useState(false);
useEffect(() => { refreshSpecies(); }, [refreshSpecies]);
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-100">Choose a Species</h2>
<p className="text-sm text-neutral-400 mt-1">Pick from the list or add your own homebrew.</p>
</div>
<SelectorGrid
items={species}
selectedId={draft.speciesId}
onSelect={(s) => update({ speciesId: s.id })}
onAddCustom={() => setBuilderOpen(true)}
addLabel="Add Species"
renderMeta={(s) => `${(s.sizes ?? []).join("/") || "—"} · ${s.speed}ft`}
/>
<Modal open={builderOpen} onClose={() => setBuilderOpen(false)} title="Create Custom Species" wide confirmClose>
<SpeciesBuilder
onCancel={() => setBuilderOpen(false)}
onSave={async (s) => {
await refreshSpecies();
update({ speciesId: s.id });
setBuilderOpen(false);
}}
/>
</Modal>
</div>
);
}
@@ -0,0 +1,268 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useCreator } from "../CreatorContext";
import type { Ability, Spell, Subclass } from "@/types/content";
import Modal from "../components/Modal";
import SpellBuilder from "../components/SpellBuilder";
interface SpellcastingSlot {
featureId: string;
featureName: string;
ability: Ability;
ritual: boolean;
spellListIds: string[];
cantripsKnown: number;
spellsKnown: number;
slots: number[];
maxSpellLevel: number;
}
export default function SpellsStep() {
const {
draft,
update,
species,
classes,
backgrounds,
subclasses,
features,
spells,
refreshSpells,
} = useCreator();
const [builderOpen, setBuilderOpen] = useState(false);
useEffect(() => { refreshSpells(); }, [refreshSpells]);
const level = draft.classes[0]?.level ?? 1;
const chosenClass = classes.find((c) => c.id === draft.classes[0]?.classId);
const chosenSpecies = species.find((s) => s.id === draft.speciesId);
const chosenBg = backgrounds.find((b) => b.id === draft.backgroundId);
const chosenSubclass: Subclass | undefined = useMemo(() => {
if (!chosenClass) return undefined;
const explicit = draft.classes[0]?.subclassId;
if (explicit) return subclasses.find((s) => s.id === explicit);
for (const lf of chosenClass.levelFeatures ?? []) {
if (lf.level > level) continue;
for (const fid of lf.featureIds) {
const f = features[fid];
if (f?.effects?.some((e) => e.target.kind === "grantSubclassChoice")) {
const pick = draft.featureChoices[fid]?.[0];
if (pick) return subclasses.find((s) => s.id === pick);
}
}
}
return undefined;
}, [chosenClass, draft, subclasses, features, level]);
const activeFeatureIds = useMemo(() => {
const ids: string[] = [];
ids.push(...(chosenSpecies?.featureIds ?? []));
ids.push(...(chosenBg?.featureIds ?? []));
for (const lf of chosenClass?.levelFeatures ?? []) {
if (lf.level <= level) ids.push(...lf.featureIds);
}
for (const lf of chosenSubclass?.levelFeatures ?? []) {
if (lf.level <= level) ids.push(...lf.featureIds);
}
return ids;
}, [chosenSpecies, chosenBg, chosenClass, chosenSubclass, level]);
const spellSlots: SpellcastingSlot[] = useMemo(() => {
const out: SpellcastingSlot[] = [];
for (const fid of activeFeatureIds) {
const f = features[fid];
if (!f) continue;
for (const eff of f.effects ?? []) {
if (eff.target.kind !== "grantSpellcasting") continue;
if ((eff.level ?? 1) > level) continue;
// Pick the highest progression row whose level ≤ character level
const applicable = eff.target.progression
.filter((r) => r.level <= level)
.sort((a, b) => b.level - a.level)[0];
if (!applicable) continue;
const maxSpellLevel = applicable.slots
.map((n, i) => (n > 0 ? i + 1 : 0))
.reduce((a, b) => Math.max(a, b), 0);
out.push({
featureId: f.id,
featureName: f.name,
ability: eff.target.ability,
ritual: eff.target.ritual === true,
spellListIds: eff.target.spellListIds ?? [],
cantripsKnown: applicable.cantripsKnown,
spellsKnown: applicable.spellsKnown,
slots: applicable.slots,
maxSpellLevel,
});
}
}
return out;
}, [activeFeatureIds, features, level]);
if (spellSlots.length === 0) {
return (
<div className="space-y-4">
<div>
<h2 className="text-2xl font-bold text-neutral-100">Spells</h2>
<p className="text-sm text-neutral-400 mt-1">Your character has no spellcasting features yet.</p>
</div>
</div>
);
}
const availableSpells = (slot: SpellcastingSlot, spellLevel: number): Spell[] => {
return spells.filter((sp) => {
if (sp.level !== spellLevel) return false;
if (slot.spellListIds.length > 0) return slot.spellListIds.includes(sp.id);
return true;
});
};
const togglePick = (slot: SpellcastingSlot, spellId: string, isCantrip: boolean) => {
const picks = (draft.featureChoices[slot.featureId] ?? []) as string[];
const key = (isCantrip ? "cantrip:" : "spell:") + spellId;
if (picks.includes(key)) {
update({
featureChoices: {
...draft.featureChoices,
[slot.featureId]: picks.filter((x) => x !== key),
},
});
return;
}
const cantripCount = picks.filter((k) => k.startsWith("cantrip:")).length;
const spellCount = picks.filter((k) => k.startsWith("spell:")).length;
if (isCantrip && cantripCount >= slot.cantripsKnown) return;
if (!isCantrip && spellCount >= slot.spellsKnown) return;
update({
featureChoices: {
...draft.featureChoices,
[slot.featureId]: [...picks, key],
},
});
};
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold text-neutral-100">Spells</h2>
<p className="text-sm text-neutral-400 mt-1">Pick your cantrips and spells for each spellcasting feature.</p>
</div>
{spellSlots.map((slot) => {
const picks = (draft.featureChoices[slot.featureId] ?? []) as string[];
const pickedCantrips = picks.filter((k) => k.startsWith("cantrip:")).length;
const pickedSpells = picks.filter((k) => k.startsWith("spell:")).length;
return (
<div key={slot.featureId} className="space-y-4">
<div className="flex flex-wrap items-baseline gap-3">
<div className="text-lg font-semibold text-neutral-100">{slot.featureName}</div>
<div className="text-xs text-neutral-500">
Ability: <span className="text-neutral-300">{slot.ability}</span>
{slot.ritual && <span className="ml-2 text-amber-400">Ritual</span>}
</div>
</div>
<div className="text-xs text-neutral-400">
Slots: {slot.slots.map((n, i) => `L${i + 1}:${n}`).filter((_, i) => slot.slots[i] > 0).join(" ")}
</div>
{slot.cantripsKnown > 0 && (
<div>
<div className="text-sm text-neutral-300 mb-2">
Cantrips ({pickedCantrips}/{slot.cantripsKnown})
</div>
<SpellPickerGrid
options={availableSpells(slot, 0)}
isPicked={(sp) => picks.includes("cantrip:" + sp.id)}
disabled={(sp) => !picks.includes("cantrip:" + sp.id) && pickedCantrips >= slot.cantripsKnown}
onToggle={(sp) => togglePick(slot, sp.id, true)}
/>
</div>
)}
{slot.spellsKnown > 0 && (
<div>
<div className="text-sm text-neutral-300 mb-2">
Spells known ({pickedSpells}/{slot.spellsKnown})
</div>
{Array.from({ length: slot.maxSpellLevel }, (_, i) => i + 1).map((spellLv) => {
const options = availableSpells(slot, spellLv);
if (options.length === 0) return null;
return (
<div key={spellLv} className="mt-3">
<div className="text-xs uppercase text-neutral-500 mb-1">Level {spellLv}</div>
<SpellPickerGrid
options={options}
isPicked={(sp) => picks.includes("spell:" + sp.id)}
disabled={(sp) => !picks.includes("spell:" + sp.id) && pickedSpells >= slot.spellsKnown}
onToggle={(sp) => togglePick(slot, sp.id, false)}
/>
</div>
);
})}
</div>
)}
</div>
);
})}
<div className="pt-4 border-t border-neutral-800">
<button
type="button"
onClick={() => setBuilderOpen(true)}
className="text-sm text-amber-400 border border-amber-400/40 hover:bg-amber-400/10 rounded px-4 py-2"
>
+ Add custom Spell to library
</button>
</div>
<Modal open={builderOpen} onClose={() => setBuilderOpen(false)} title="Create Custom Spell" wide confirmClose>
<SpellBuilder
onCancel={() => setBuilderOpen(false)}
onSave={async () => { await refreshSpells(); setBuilderOpen(false); }}
/>
</Modal>
</div>
);
}
function SpellPickerGrid({
options,
isPicked,
disabled,
onToggle,
}: {
options: Spell[];
isPicked: (sp: Spell) => boolean;
disabled: (sp: Spell) => boolean;
onToggle: (sp: Spell) => void;
}) {
if (options.length === 0) {
return <p className="text-xs text-neutral-500 italic">No spells available. Add custom below.</p>;
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{options.map((sp) => {
const on = isPicked(sp);
const off = disabled(sp);
return (
<button
key={sp.id}
type="button"
onClick={() => onToggle(sp)}
disabled={off}
className={`text-left p-2 rounded border text-sm transition ${
on ? "bg-amber-500/10 border-amber-500" : "border-neutral-700 hover:border-neutral-500"
} ${off ? "opacity-40 cursor-not-allowed" : ""}`}
>
<div className="font-medium text-neutral-100">{sp.name}</div>
{sp.school && <div className="text-xs text-neutral-500">{sp.school}</div>}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,156 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useCreator } from "../CreatorContext";
import type { Subclass } from "@/types/content";
import Modal from "../components/Modal";
import SubclassBuilder from "../components/SubclassBuilder";
interface GrantSlot {
featureId: string;
featureName: string;
label: string;
classId: string;
className: string;
}
export default function SubclassStep() {
const {
draft,
update,
classes,
features,
subclasses,
refreshSubclasses,
} = useCreator();
const [builderFor, setBuilderFor] = useState<GrantSlot | null>(null);
useEffect(() => { refreshSubclasses(); }, [refreshSubclasses]);
const selectedClass = classes.find((c) => c.id === draft.classes[0]?.classId);
const level = draft.classes[0]?.level ?? 1;
const grantSlots: GrantSlot[] = useMemo(() => {
if (!selectedClass) return [];
const activeFeatureIds = new Set<string>();
for (const lf of selectedClass.levelFeatures ?? []) {
if (lf.level <= level) for (const fid of lf.featureIds) activeFeatureIds.add(fid);
}
const slots: GrantSlot[] = [];
for (const fid of activeFeatureIds) {
const f = features[fid];
if (!f) continue;
for (const eff of f.effects ?? []) {
if (
eff.target.kind === "grantSubclassChoice" &&
(eff.level ?? 1) <= level
) {
slots.push({
featureId: f.id,
featureName: f.name,
label: eff.target.label || f.name,
classId: selectedClass.id,
className: selectedClass.name,
});
}
}
}
return slots;
}, [selectedClass, level, features]);
const pick = (slot: GrantSlot, subclass: Subclass) => {
update({
featureChoices: { ...draft.featureChoices, [slot.featureId]: [subclass.id] },
});
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-100">Subclass</h2>
<p className="text-sm text-neutral-400 mt-1">
Class features that grant a subclass choice appear here.
</p>
</div>
{grantSlots.length === 0 && (
<p className="text-sm text-neutral-500 italic">
No subclass choice is available yet. Level up or pick a class whose features grant one.
</p>
)}
{grantSlots.map((slot) => {
const currentId = draft.featureChoices[slot.featureId]?.[0];
const options = subclasses.filter((s) => s.classId === slot.classId);
return (
<div key={slot.featureId} className="space-y-3">
<div className="flex items-baseline gap-3">
<div className="text-lg font-semibold text-neutral-100">{slot.label}</div>
<div className="text-xs text-neutral-500">from {slot.featureName}</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{options.map((sc) => {
const isSelected = sc.id === currentId;
return (
<button
key={sc.id}
type="button"
onClick={() => pick(slot, sc)}
className={`text-left p-4 rounded-lg border transition ${
isSelected
? "border-amber-500 bg-amber-500/10"
: "border-neutral-700 bg-neutral-800/60 hover:bg-neutral-800 hover:border-neutral-500"
}`}
>
<div className="flex items-start justify-between gap-2">
<span className="font-semibold text-neutral-100">{sc.name}</span>
{sc.ruleset && (
<span className="text-[10px] uppercase tracking-wide text-neutral-400 border border-neutral-600 px-1.5 py-0.5 rounded">
{sc.ruleset}
</span>
)}
</div>
{sc.description && (
<p className="mt-2 text-sm text-neutral-400 line-clamp-3">{sc.description}</p>
)}
</button>
);
})}
<button
type="button"
onClick={() => setBuilderFor(slot)}
className="p-4 rounded-lg border-2 border-dashed border-neutral-600 text-neutral-400 hover:border-amber-500 hover:text-amber-400 transition flex items-center justify-center gap-2"
>
<span className="text-2xl leading-none">+</span>
<span className="font-medium">Add Subclass</span>
</button>
</div>
</div>
);
})}
<Modal
open={!!builderFor}
onClose={() => setBuilderFor(null)}
title={`Create Custom ${builderFor?.label ?? "Subclass"}`}
wide
confirmClose
>
{builderFor && (
<SubclassBuilder
classId={builderFor.classId}
className={builderFor.className}
onCancel={() => setBuilderFor(null)}
onSave={async (sc) => {
await refreshSubclasses();
pick(builderFor, sc);
setBuilderFor(null);
}}
/>
)}
</Modal>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import type { Character, ClassDef, Species } from "@/types/content";
interface CharDoc extends Partial<Character> {
_id?: string;
id?: string;
name?: string;
}
export default function CharactersClient() {
const [chars, setChars] = useState<CharDoc[] | null>(null);
const [species, setSpecies] = useState<Species[]>([]);
const [classes, setClasses] = useState<ClassDef[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const load = async () => {
try {
const [charsRes, speciesRes, classesRes] = await Promise.all([
fetch("/api/character"),
fetch("/api/species"),
fetch("/api/classes"),
]);
const [charsJson, speciesJson, classesJson] = await Promise.all([
charsRes.json().catch(() => ({})),
speciesRes.json().catch(() => ({})),
classesRes.json().catch(() => ({})),
]);
setChars(charsJson.results ?? []);
setSpecies(speciesJson.results ?? []);
setClasses(classesJson.results ?? []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load characters");
setChars([]);
}
};
load();
}, []);
const speciesName = (id?: string) => species.find((s) => s.id === id)?.name ?? "—";
const classSummary = (c: CharDoc) => {
const first = c.classes?.[0];
if (!first) return "—";
const name = classes.find((x) => x.id === first.classId)?.name ?? "Class";
return `${name} Lv ${first.level}`;
};
if (chars === null) {
return (
<div className="flex justify-center items-center h-[75vh] text-neutral-400">Loading...</div>
);
}
return (
<div className="max-w-6xl mx-auto p-6 md:p-10">
<div className="mb-8">
<h1 className="text-3xl font-bold text-ink">Your Characters</h1>
<p className="text-sm text-neutral-400 mt-1">Pick a character or create a new one.</p>
{error && <p className="text-sm text-red-400 mt-2">{error}</p>}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{chars.map((c, i) => (
<Link
key={c.id ?? c._id ?? i}
href={c.id ? `/charactersheet?id=${c.id}` : "/charactersheet"}
className="block p-5 bg-surface border border-gold/20 rounded-2xl hover:bg-gold/10 hover:border-gold/40 hover:shadow-lg hover:shadow-gold/20 transition-all"
>
<div className="text-xl font-bold text-ink">{c.name || "Unnamed"}</div>
<div className="mt-2 text-sm text-neutral-400 space-y-1">
<div>{speciesName(c.speciesId)}</div>
<div>{classSummary(c)}</div>
{c.hp?.max != null && (
<div className="text-xs text-neutral-500">HP {c.hp.current ?? c.hp.max}/{c.hp.max}</div>
)}
</div>
</Link>
))}
<Link
href="/charactercreator"
className="flex flex-col items-center justify-center p-5 min-h-[10rem] rounded-2xl border-2 border-dashed border-gold/30 text-gold hover:border-gold/60 hover:bg-gold/5 transition-all"
>
<span className="text-4xl leading-none">+</span>
<span className="mt-2 font-semibold">Create a new character</span>
</Link>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import CharactersClient from "./CharactersClient";
export default async function Page() {
const session = await auth();
if (!session) {
redirect("/login?callbackUrl=/characters");
}
return <CharactersClient />;
}
+893
View File
@@ -0,0 +1,893 @@
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useSession } from "next-auth/react";
import UserIcon from "@iconify-react/fa6-solid/user";
import type {
Background,
Character,
ClassDef,
Feature,
Species,
Spell,
Subclass,
} from "@/types/content";
import { ABILITIES, SKILL_ABILITY, type Ability, type Skill } from "@/types/content";
import {
abilityModifier,
hasAdvantage,
hasDisadvantage,
resolveCharacter,
savingThrow,
skillBonus,
} from "@/lib/applyFeatures";
import { getCharacterLevel, getProficiencyBonus } from "@/lib/dndHelpers";
import { useRollHistory } from "@/components/RollHistoryContext";
const SKILLS = Object.keys(SKILL_ABILITY) as Skill[];
const ALIGNMENT_LABELS: Record<string, string> = {
lawfulGood: "Lawful Good",
neutralGood: "Neutral Good",
chaoticGood: "Chaotic Good",
lawfulNeutral: "Lawful Neutral",
trueNeutral: "True Neutral",
chaoticNeutral: "Chaotic Neutral",
lawfulEvil: "Lawful Evil",
neutralEvil: "Neutral Evil",
chaoticEvil: "Chaotic Evil",
};
// 2×3 grid order for saves — physical/mental columns
const SAVE_ORDER: Ability[] = [
"strength", "intelligence",
"dexterity", "wisdom",
"constitution", "charisma",
];
function fmt(m: number): string {
return m >= 0 ? `+${m}` : `${m}`;
}
export default function CharacterSheet() {
const searchParams = useSearchParams();
const id = searchParams.get("id");
const { roll } = useRollHistory();
const { data: session } = useSession();
const [character, setCharacter] = useState<Character | null>(null);
const [species, setSpecies] = useState<Species[]>([]);
const [classes, setClasses] = useState<ClassDef[]>([]);
const [subclasses, setSubclasses] = useState<Subclass[]>([]);
const [backgrounds, setBackgrounds] = useState<Background[]>([]);
const [features, setFeatures] = useState<Feature[]>([]);
const [spells, setSpells] = useState<Spell[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) {
setLoading(false);
return;
}
const load = async () => {
try {
const res = await fetch(`/api/character/${encodeURIComponent(id)}`);
if (!res.ok) {
const { error: err } = await res.json().catch(() => ({}));
setError(err ?? `Failed to load (${res.status})`);
return;
}
const data = await res.json();
setCharacter(data.character ?? null);
setSpecies(data.species ?? []);
setClasses(data.classes ?? []);
setSubclasses(data.subclasses ?? []);
setBackgrounds(data.backgrounds ?? []);
setFeatures(data.features ?? []);
setSpells(data.spells ?? []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load");
} finally {
setLoading(false);
}
};
load();
}, [id]);
const isOwner = Boolean(
character && session?.user?.id && (
(character as unknown as { user_id?: string }).user_id === session.user.id ||
character.userId === session.user.id
)
);
const featureDict = useMemo(() => {
const d: Record<string, Feature> = {};
for (const f of features) d[f.id] = f;
return d;
}, [features]);
const spellDict = useMemo(() => {
const d: Record<string, Spell> = {};
for (const s of spells) d[s.id] = s;
return d;
}, [spells]);
const resolved = useMemo(() => {
if (!character) return null;
return resolveCharacter(character, featureDict);
}, [character, featureDict]);
const doRoll = async (opts: {
pool: Record<number, number>;
label: string;
modifier?: number;
advantage?: "adv" | "dis" | "none";
}) => {
if (!isOwner) return;
await roll(opts);
};
if (loading) {
return <div className="p-8 text-center text-neutral-400">Loading character...</div>;
}
if (error) {
return <div className="p-8 text-center text-red-400">{error}</div>;
}
if (!character || !resolved) {
return (
<div className="p-8 text-center text-neutral-400">
Character not found.{" "}
<Link href="/characters" className="text-gold underline">Back to characters</Link>
</div>
);
}
const speciesName = species.find((s) => s.id === character.speciesId)?.name ?? "Unknown Species";
const cls = classes.find((c) => c.id === character.classes[0]?.classId);
const subclass = character.classes[0]?.subclassId
? subclasses.find((s) => s.id === character.classes[0]?.subclassId)
: undefined;
const background = backgrounds.find((b) => b.id === character.backgroundId);
const level = getCharacterLevel(character);
const pb = getProficiencyBonus(level);
return (
<div className="max-w-6xl mx-auto p-4 md:p-8 space-y-6">
<Header
character={character}
speciesName={speciesName}
classes={classes}
subclasses={subclasses}
backgroundName={background?.name}
level={level}
pb={pb}
resolved={resolved}
onRoll={doRoll}
/>
{/* Ability row spans full width */}
<AbilityBlock resolved={resolved} onRoll={doRoll} />
{/* 3-col: saves stack | skills | actions/side panels */}
<div className="grid grid-cols-1 lg:grid-cols-[auto_1fr_auto] gap-4 items-start">
<div className="space-y-4">
<SavingThrows resolved={resolved} onRoll={doRoll} />
<PassiveScores resolved={resolved} />
<DeathSaves isOwner={isOwner} />
</div>
<div className="space-y-4">
<SkillsBlock resolved={resolved} onRoll={doRoll} />
<ProficienciesBlock resolved={resolved} />
<DefensesBlock resolved={resolved} />
</div>
<div className="space-y-4 lg:w-72">
<ActionsBlock resolved={resolved} />
<NotesBlock resolved={resolved} />
</div>
</div>
<div className="space-y-4">
<SpellsBlock character={character} spellDict={spellDict} resolved={resolved} onRoll={doRoll} isOwner={isOwner} />
<FeaturesList character={character} featureDict={featureDict} />
</div>
</div>
);
}
function Header({
character,
speciesName,
classes,
subclasses,
backgroundName,
level,
pb,
resolved,
onRoll,
}: {
character: Character;
speciesName: string;
classes: ClassDef[];
subclasses: Subclass[];
backgroundName?: string;
level: number;
pb: number;
resolved: ReturnType<typeof resolveCharacter>;
onRoll: RollFn;
}) {
const dexMod = abilityModifier(resolved, "dexterity");
const ac = 10 + dexMod + resolved.acBonus;
const initiative = dexMod + resolved.initiativeBonus;
const hpMax = (character.hp?.max ?? 0) + resolved.hpMaxBonus;
const hpCurrent = character.hp?.current ?? hpMax;
const classRows = (character.classes ?? []).map((cc) => {
const cls = classes.find((c) => c.id === cc.classId);
const sub = cc.subclassId ? subclasses.find((s) => s.id === cc.subclassId) : undefined;
return {
classId: cc.classId,
name: cls?.name ?? "Unknown Class",
level: cc.level,
subclassName: sub?.name,
};
});
return (
<div className="p-5 bg-surface border border-gold/20 rounded-2xl flex flex-col md:flex-row gap-5">
<div className="flex-shrink-0">
<div className="w-24 h-24 md:w-32 md:h-32 rounded-2xl border-2 border-gold/40 bg-gradient-to-br from-neutral-800 to-neutral-950 flex items-center justify-center text-gold/70">
<UserIcon height="3em" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-3 flex-wrap">
<h1 className="text-3xl font-bold text-ink truncate">{character.name}</h1>
<span className="text-xl text-gold/80 font-semibold">Lv {level}</span>
</div>
<div className="mt-2 space-y-1 text-sm">
{classRows.map((row) => (
<div key={row.classId} className="text-neutral-300">
<span className="font-semibold text-ink">{row.name}</span>{" "}
<span className="text-gold">Lv {row.level}</span>
{row.subclassName && (
<span className="text-neutral-500"> · {row.subclassName}</span>
)}
</div>
))}
</div>
<div className="mt-2 text-sm text-neutral-400 flex flex-wrap gap-x-4 gap-y-1">
<div><span className="text-neutral-500">Species:</span> {speciesName}</div>
{backgroundName && (
<div><span className="text-neutral-500">Background:</span> {backgroundName}</div>
)}
{character.alignment && (
<div>
<span className="text-neutral-500">Alignment:</span>{" "}
{ALIGNMENT_LABELS[character.alignment] ?? character.alignment}
</div>
)}
</div>
<div className="mt-3 grid grid-cols-3 sm:grid-cols-5 gap-2 text-sm">
<Stat label="HP" value={`${hpCurrent}/${hpMax}`} />
<Stat label="AC" value={String(ac)} />
<button
onClick={() => onRoll({ pool: { 20: 1 }, label: "Initiative", modifier: initiative })}
className="p-2 rounded-lg border border-neutral-700 hover:border-gold/60 hover:bg-gold/5 transition text-center"
title="Roll initiative"
>
<div className="text-[10px] uppercase text-neutral-500">Init</div>
<div className="text-lg font-bold text-gold">{fmt(initiative)}</div>
</button>
<Stat label="Speed" value={`${resolved.speed}ft`} />
<Stat label="Prof" value={`+${pb}`} />
</div>
</div>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="p-2 rounded-lg border border-neutral-700 text-center">
<div className="text-[10px] uppercase text-neutral-500">{label}</div>
<div className="text-lg font-bold text-ink">{value}</div>
</div>
);
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="p-4 bg-surface border border-gold/20 rounded-2xl">
<h2 className="text-xs uppercase tracking-wider text-gold mb-3">{title}</h2>
{children}
</section>
);
}
type RollFn = (opts: {
pool: Record<number, number>;
label: string;
modifier?: number;
advantage?: "adv" | "dis" | "none";
}) => Promise<void>;
function AbilityBlock({
resolved,
onRoll,
}: {
resolved: ReturnType<typeof resolveCharacter>;
onRoll: RollFn;
}) {
return (
<Panel title="Ability Scores">
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
{ABILITIES.map((a) => {
const score = resolved.abilities[a];
const mod = abilityModifier(resolved, a);
const adv = hasAdvantage(resolved, { on: "check", ability: a });
const dis = hasDisadvantage(resolved, { on: "check", ability: a });
const advantage = adv.yes && !dis.yes ? "adv" : dis.yes && !adv.yes ? "dis" : "none";
return (
<button
key={a}
onClick={() => onRoll({ pool: { 20: 1 }, label: `${a} check`, modifier: mod, advantage })}
className="group flex flex-col items-center justify-center rounded-xl border border-neutral-700 hover:border-gold/60 hover:bg-gold/5 transition px-2 py-1.5 leading-tight"
>
<div className="text-[10px] uppercase text-neutral-500">{a.slice(0, 3)}</div>
<div className="text-xl font-bold text-gold">{fmt(mod)}</div>
<div className="text-xs text-neutral-400">{score}</div>
</button>
);
})}
</div>
</Panel>
);
}
function SavingThrows({
resolved,
onRoll,
}: {
resolved: ReturnType<typeof resolveCharacter>;
onRoll: RollFn;
}) {
return (
<Panel title="Saving Throws">
<div className="grid grid-cols-2 gap-1.5 w-max">
{SAVE_ORDER.map((a) => {
const bonus = savingThrow(resolved, a);
const prof = resolved.savingThrowProficiencies.has(a);
const adv = hasAdvantage(resolved, { on: "save", ability: a });
const dis = hasDisadvantage(resolved, { on: "save", ability: a });
const advantage = adv.yes && !dis.yes ? "adv" : dis.yes && !adv.yes ? "dis" : "none";
return (
<button
key={a}
onClick={() => onRoll({ pool: { 20: 1 }, label: `${a} save`, modifier: bonus, advantage })}
className="group flex items-center gap-2 px-2 py-1 rounded border border-neutral-700 hover:border-gold/60 hover:bg-gold/5 transition"
>
<span className={`inline-block w-1.5 h-1.5 rounded-full ${prof ? "bg-gold" : "bg-neutral-700"}`} />
<span className="text-xs capitalize text-neutral-300">{a.slice(0, 3)}</span>
<span className="font-mono text-sm text-gold ml-auto">{fmt(bonus)}</span>
{advantage === "adv" && <span className="text-[9px] text-emerald-400">adv</span>}
{advantage === "dis" && <span className="text-[9px] text-red-400">dis</span>}
</button>
);
})}
</div>
</Panel>
);
}
function PassiveScores({ resolved }: { resolved: ReturnType<typeof resolveCharacter> }) {
const rows: [string, Skill][] = [
["Perception", "perception"],
["Investigation", "investigation"],
["Insight", "insight"],
];
return (
<Panel title="Passive Scores">
<div className="space-y-1 w-max">
{rows.map(([label, skill]) => {
const bonus = skillBonus(resolved, skill);
return (
<div key={skill} className="flex items-center gap-3 text-sm">
<span className="text-neutral-400 flex-1">{label}</span>
<span className="font-mono text-gold w-8 text-right">{10 + bonus}</span>
</div>
);
})}
</div>
</Panel>
);
}
function DeathSaves({ isOwner }: { isOwner: boolean }) {
const [successes, setSuccesses] = useState(0);
const [failures, setFailures] = useState(0);
const setS = (n: number) => { if (isOwner) setSuccesses(n); };
const setF = (n: number) => { if (isOwner) setFailures(n); };
return (
<Panel title="Death Saves">
<div className="space-y-2 w-max">
<div className="flex items-center gap-2">
<span className="text-xs text-emerald-400 w-16">Successes</span>
{[1, 2, 3].map((n) => (
<button
key={n}
onClick={() => setS(successes === n ? n - 1 : n)}
className={`w-4 h-4 rounded-full border ${
successes >= n ? "bg-emerald-400 border-emerald-400" : "border-neutral-600"
}`}
aria-label={`Success ${n}`}
/>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-red-400 w-16">Failures</span>
{[1, 2, 3].map((n) => (
<button
key={n}
onClick={() => setF(failures === n ? n - 1 : n)}
className={`w-4 h-4 rounded-full border ${
failures >= n ? "bg-red-400 border-red-400" : "border-neutral-600"
}`}
aria-label={`Failure ${n}`}
/>
))}
</div>
</div>
</Panel>
);
}
function SkillsBlock({
resolved,
onRoll,
}: {
resolved: ReturnType<typeof resolveCharacter>;
onRoll: RollFn;
}) {
return (
<Panel title="Skills">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-0.5">
{SKILLS.map((s) => {
const bonus = skillBonus(resolved, s);
const prof = resolved.skillProficiencies.has(s);
const exp = resolved.skillExpertise.has(s);
const adv = hasAdvantage(resolved, { on: "skill", skill: s });
const dis = hasDisadvantage(resolved, { on: "skill", skill: s });
const advantage = adv.yes && !dis.yes ? "adv" : dis.yes && !adv.yes ? "dis" : "none";
return (
<div
key={s}
className="flex items-center gap-2 px-1 py-0.5 text-sm"
title={SKILL_ABILITY[s]}
>
<span
className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${
exp ? "bg-purple-400" : prof ? "bg-gold" : "bg-neutral-700"
}`}
/>
<span className="text-[10px] text-neutral-500 w-8 uppercase">
{SKILL_ABILITY[s].slice(0, 3)}
</span>
<span className="capitalize truncate flex-1">{s.replace(/([A-Z])/g, " $1")}</span>
{advantage === "adv" && <span className="text-[10px] text-emerald-400">adv</span>}
{advantage === "dis" && <span className="text-[10px] text-red-400">dis</span>}
<button
onClick={() => onRoll({ pool: { 20: 1 }, label: `${s} check`, modifier: bonus, advantage })}
className="w-12 px-2 py-0.5 rounded border border-neutral-700 hover:border-gold/60 hover:bg-gold/10 transition font-mono text-gold text-center text-sm"
>
{fmt(bonus)}
</button>
</div>
);
})}
</div>
</Panel>
);
}
function ActionsBlock({ resolved }: { resolved: ReturnType<typeof resolveCharacter> }) {
const has = resolved.actions.length + resolved.bonusActions.length + resolved.reactions.length > 0;
if (!has) return null;
return (
<Panel title="Actions">
{resolved.actions.length > 0 && (
<ActionList title="Action" items={resolved.actions} accent="border-blue-500/40 text-blue-300" />
)}
{resolved.bonusActions.length > 0 && (
<ActionList title="Bonus Action" items={resolved.bonusActions} accent="border-amber-500/40 text-amber-300" />
)}
{resolved.reactions.length > 0 && (
<ActionList title="Reaction" items={resolved.reactions} accent="border-purple-500/40 text-purple-300" />
)}
</Panel>
);
}
function ActionList({
title,
items,
accent,
}: {
title: string;
items: { sourceName: string; sourceCategory: string; note: string }[];
accent: string;
}) {
return (
<div className="mb-3 last:mb-0">
<div className={`text-xs font-semibold mb-1 ${accent.split(" ")[1]}`}>{title}</div>
<div className="space-y-1">
{items.map((it, i) => (
<div key={i} className={`p-2 border rounded text-sm ${accent}`}>
<span className="text-neutral-100">{it.note}</span>
{it.sourceName && <span className="ml-2 text-xs text-neutral-500"> {it.sourceName}</span>}
</div>
))}
</div>
</div>
);
}
function ProficienciesBlock({ resolved }: { resolved: ReturnType<typeof resolveCharacter> }) {
const rows: [string, Set<string>][] = [
["Armor", resolved.armorProficiencies],
["Weapons", resolved.weaponProficiencies],
["Tools", resolved.toolProficiencies],
["Languages", resolved.languages],
];
const anything = rows.some(([, set]) => set.size > 0);
if (!anything) return null;
return (
<Panel title="Proficiencies & Languages">
<div className="space-y-1 text-sm">
{rows.map(([label, set]) =>
set.size > 0 ? (
<div key={label}>
<span className="text-neutral-500">{label}:</span>{" "}
<span className="text-neutral-100">{[...set].join(", ")}</span>
</div>
) : null
)}
</div>
</Panel>
);
}
function DefensesBlock({ resolved }: { resolved: ReturnType<typeof resolveCharacter> }) {
const rows: [string, Set<string>][] = [
["Resistances", resolved.resistances],
["Immunities", resolved.immunities],
["Vulnerabilities", resolved.vulnerabilities],
];
const anything = rows.some(([, set]) => set.size > 0);
if (!anything) return null;
return (
<Panel title="Damage Defenses">
<div className="space-y-1 text-sm">
{rows.map(([label, set]) =>
set.size > 0 ? (
<div key={label}>
<span className="text-neutral-500">{label}:</span>{" "}
<span className="text-neutral-100">{[...set].join(", ")}</span>
</div>
) : null
)}
</div>
</Panel>
);
}
function SpellsBlock({
character,
spellDict,
resolved,
onRoll,
isOwner,
}: {
character: Character;
spellDict: Record<string, Spell>;
resolved: ReturnType<typeof resolveCharacter>;
onRoll: RollFn;
isOwner: boolean;
}) {
const spellBuckets = character.magic?.spells ?? {};
const savedSlots = character.magic?.slots ?? {};
const grantedIds = [...resolved.grantedSpells];
const knownIds = new Set<string>([...Object.values(spellBuckets).flat(), ...grantedIds]);
// Local tracker for expended slots. Keyed by spell-level string. Not persisted yet.
// Default: all slots available (used = 0).
const [used, setUsed] = useState<Record<string, number>>({});
if (knownIds.size === 0 && Object.keys(savedSlots).length === 0) return null;
const bySpellLevel: Record<number, Spell[]> = {};
for (const id of knownIds) {
const s = spellDict[id];
if (!s) continue;
(bySpellLevel[s.level] ||= []).push(s);
}
const spellLevelsWithSlots = Object.keys(savedSlots)
.map(Number)
.filter((n) => (savedSlots[String(n)]?.[1] ?? 0) > 0)
.sort((a, b) => a - b);
const spellLevelsWithSpells = Object.keys(bySpellLevel).map(Number).sort((a, b) => a - b);
const toggleSlot = (lv: number, index: number, max: number) => {
if (!isOwner) return;
const key = String(lv);
const currentUsed = used[key] ?? 0;
// index counts from 0 to max-1. Filled = unused.
// Filled circles are (max - currentUsed) leftmost. Click a filled circle → mark as used (index >= max - currentUsed - 1 becomes used).
// Click an empty circle → mark as available.
const wasEmpty = index >= max - currentUsed;
let nextUsed;
if (wasEmpty) {
// Restore: make everything at/before index filled
nextUsed = max - (index + 1);
} else {
// Spend: consume down to this position
nextUsed = max - index;
}
setUsed({ ...used, [key]: Math.max(0, Math.min(max, nextUsed)) });
};
// Union of all levels with slots OR spells, sorted asc. Cantrips (0) always last.
const allLevels = Array.from(
new Set([...spellLevelsWithSlots, ...spellLevelsWithSpells])
).sort((a, b) => {
if (a === 0) return 1;
if (b === 0) return -1;
return a - b;
});
if (allLevels.length === 0) {
return (
<Panel title="Spells">
<p className="text-sm text-neutral-500 italic">No spells known.</p>
</Panel>
);
}
return (
<Panel title="Spells">
<div className="space-y-4">
{allLevels.map((lv) => {
const spellsAtLv = bySpellLevel[lv] ?? [];
const max = savedSlots[String(lv)]?.[1] ?? 0;
const currentUsed = used[String(lv)] ?? 0;
const available = max - currentUsed;
return (
<div key={lv}>
<div className="flex items-center justify-between mb-2 gap-3">
<div className="text-sm font-semibold text-gold">
{lv === 0 ? "Cantrips" : `Level ${lv}`}
</div>
{max > 0 && (
<div className="flex items-center gap-2">
<div className="flex gap-1">
{Array.from({ length: max }).map((_, i) => {
const filled = i < available;
return (
<button
key={i}
onClick={() => toggleSlot(lv, i, max)}
className={`w-4 h-4 rounded-full border-2 transition ${
filled
? "bg-gold border-gold hover:bg-gold-glow"
: "border-neutral-600 hover:border-gold/60"
}`}
aria-label={`Slot ${i + 1} of Level ${lv}, ${filled ? "available" : "used"}`}
/>
);
})}
</div>
<span className="text-xs text-neutral-500 font-mono">{available}/{max}</span>
</div>
)}
</div>
{spellsAtLv.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1">
{spellsAtLv.map((s) => (
<button
key={s.id}
onClick={() => onRoll({ pool: { 20: 1 }, label: `${s.name} (attack)`, modifier: 0 })}
className="text-left p-2 border border-neutral-700 rounded hover:border-gold/50 text-sm"
title={s.description}
>
<div className="font-medium">{s.name}</div>
<div className="text-xs text-neutral-500">
{s.school} · {s.castingTime}
</div>
</button>
))}
</div>
) : (
<p className="text-xs text-neutral-500 italic">No spells known at this level.</p>
)}
</div>
);
})}
</div>
</Panel>
);
}
function FeaturesList({
character,
featureDict,
}: {
character: Character;
featureDict: Record<string, Feature>;
}) {
const grouped: Record<string, Feature[]> = { species: [], class: [], background: [], feat: [], custom: [] };
const level = getCharacterLevel(character);
const classLevels: Record<string, number> = {};
for (const c of character.classes ?? []) classLevels[c.classId] = c.level;
for (const app of character.appliedFeatures ?? []) {
const f = featureDict[app.featureId];
if (!f) continue;
const relLevel = app.source === "class" ? classLevels[app.sourceId] ?? level : level;
if ((f.level ?? 1) > relLevel) continue;
const bucket = app.source in grouped ? app.source : "custom";
grouped[bucket].push(f);
}
const nonEmpty = Object.entries(grouped).filter(([, arr]) => arr.length > 0);
if (nonEmpty.length === 0) return null;
return (
<Panel title="Features & Traits">
<div className="space-y-3">
{nonEmpty.map(([bucket, arr]) => (
<div key={bucket}>
<div className="text-xs uppercase text-neutral-500 mb-2">{bucket}</div>
<div className="space-y-2">
{arr.map((f) => (
<FeatureItem key={f.id} feature={f} />
))}
</div>
</div>
))}
</div>
</Panel>
);
}
function FeatureItem({ feature }: { feature: Feature }) {
const [open, setOpen] = useState(false);
return (
<div className="border border-neutral-700 rounded overflow-hidden w-full min-w-0 max-w-full">
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-gold/5 transition text-left"
>
<span
className={`inline-block transition-transform text-gold flex-shrink-0 ${open ? "rotate-90" : ""}`}
aria-hidden
>
</span>
<span className="font-medium text-sm text-ink truncate flex-1 min-w-0">{feature.name}</span>
{feature.level != null && (
<span className="text-xs text-neutral-500 flex-shrink-0">Lv {feature.level}</span>
)}
</button>
{open && (
<div className="px-3 py-2 border-t border-neutral-800 bg-neutral-950/40 w-full min-w-0 max-w-full overflow-hidden">
{feature.description ? (
<p className="text-sm text-neutral-300 whitespace-pre-wrap [overflow-wrap:anywhere] [word-break:break-word] max-w-full">
{feature.description}
</p>
) : (
<p className="text-xs text-neutral-500 italic">No description.</p>
)}
</div>
)}
</div>
);
}
function NotesBlock({ resolved }: { resolved: ReturnType<typeof resolveCharacter> }) {
const { advantages, disadvantages, notes } = resolved;
if (advantages.length + disadvantages.length + notes.length === 0) return null;
const describeScope = (s: { on: string } & Record<string, unknown>): string => {
if (s.on === "attack") return "attack rolls";
if (s.on === "save") return `${(s as { ability?: Ability }).ability ?? "all"} saves`;
if (s.on === "skill") return `${(s as { skill?: Skill }).skill} check`;
if (s.on === "check") return `${(s as { ability?: Ability }).ability ?? "all"} checks`;
return "";
};
interface GroupedEntry {
kind: "adv" | "dis" | "note";
text: string;
condition?: string;
sourceName: string;
}
const byCategory: Record<string, Record<string, GroupedEntry[]>> = {};
const push = (e: { sourceCategory: string; sourceName: string }, item: GroupedEntry) => {
const cat = e.sourceCategory || "custom";
const feat = e.sourceName || "Untitled";
(byCategory[cat] ||= {});
(byCategory[cat][feat] ||= []).push(item);
};
for (const a of advantages) {
push(a, { kind: "adv", text: `Advantage on ${describeScope(a.scope)}`, condition: a.condition, sourceName: a.sourceName });
}
for (const d of disadvantages) {
push(d, { kind: "dis", text: `Disadvantage on ${describeScope(d.scope)}`, condition: d.condition, sourceName: d.sourceName });
}
for (const n of notes) {
push(n, { kind: "note", text: n.note, sourceName: n.sourceName });
}
const CATEGORY_ORDER = ["species", "class", "subclass", "background", "feat", "custom"];
const CATEGORY_LABELS: Record<string, string> = {
species: "Species",
class: "Class",
subclass: "Subclass",
background: "Background",
feat: "Feats",
custom: "Other",
};
const sortedCats = Object.keys(byCategory).sort(
(a, b) => (CATEGORY_ORDER.indexOf(a) - CATEGORY_ORDER.indexOf(b)) || a.localeCompare(b)
);
const kindColor = (k: GroupedEntry["kind"]) =>
k === "adv" ? "text-emerald-400" : k === "dis" ? "text-red-400" : "text-neutral-300";
return (
<Panel title="Notes & Modifiers">
<div className="space-y-3">
{sortedCats.map((cat) => {
const featMap = byCategory[cat];
const feats = Object.keys(featMap).sort();
return (
<div key={cat}>
<div className="text-[10px] uppercase text-neutral-500 tracking-wider mb-1">
{CATEGORY_LABELS[cat] ?? cat}
</div>
<div className="space-y-2">
{feats.map((featName) => (
<div key={featName}>
<div className="text-xs font-semibold text-neutral-200">{featName}</div>
<ul className="text-xs list-disc list-inside space-y-0.5 ml-1">
{featMap[featName].map((e, i) => (
<li key={i} className={kindColor(e.kind)}>
{e.text}
{e.condition && <span className="text-neutral-500"> {e.condition}</span>}
</li>
))}
</ul>
</div>
))}
</div>
</div>
);
})}
</div>
</Panel>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { Suspense } from "react";
import ManualDiceLayout from "../ManualDiceLayout";
import CharacterSheet from "./CharacterSheet";
export default function Page() {
return (
<ManualDiceLayout>
<Suspense fallback={<div className="p-8 text-center text-neutral-400">Loading...</div>}>
<CharacterSheet />
</Suspense>
</ManualDiceLayout>
);
}
+32 -2
View File
@@ -14,12 +14,20 @@
box-sizing: border-box;
}
button:not(:disabled) {
cursor: pointer;
}
html {
overflow-y: scroll;
scrollbar-gutter: stable;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
min-height: 100%;
background: var(--color-bg);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--color-ink);
@@ -50,4 +58,26 @@ canvas {
opacity: 1;
transform: scaleX(1) scaleY(1) translateY(-50%);
}
}
@keyframes cardIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes cardOut {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(20px);
}
}
+18 -1
View File
@@ -1,5 +1,11 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { SessionProvider } from "next-auth/react";
import { DiceProvider } from "@/components/DiceContext";
import { RollHistoryProvider } from "@/components/RollHistoryContext";
import RollHistorySidebar from "@/components/RollHistorySidebar";
import RollToasts from "@/components/RollToasts";
import Navbar from "@/components/Navbar";
import "./globals.css";
const geistSans = Geist({
@@ -27,7 +33,18 @@ export default function RootLayout({
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
<SessionProvider>
<DiceProvider>
<RollHistoryProvider>
<Navbar />
{children}
<RollHistorySidebar />
<RollToasts />
</RollHistoryProvider>
</DiceProvider>
</SessionProvider>
</body>
</html>
);
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const router = useRouter();
const [mode, setMode] = useState<"login" | "register">("login");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
console.log("[login] submit fired, mode:", mode);
setError("");
setLoading(true);
try {
if (mode === "register") {
if (!name.trim()) {
setError("Name required");
setLoading(false);
return;
}
console.log("[login] POST /api/auth/register");
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
});
console.log("[login] register status", res.status);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || `Registration failed (${res.status})`);
setLoading(false);
return;
}
}
console.log("[login] calling signIn");
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
console.log("[login] signIn result:", result);
if (!result || result.error) {
setError(mode === "register" ? "Account created but sign-in failed. Try logging in." : "Invalid email or password");
setLoading(false);
return;
}
setLoading(false);
router.push("/");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
setLoading(false);
}
}
return (
<div className="flex items-center justify-center min-h-screen">
<div className="w-full max-w-sm p-8 bg-surface border border-gold/20 rounded-2xl shadow-lg">
<h1 className="text-2xl font-bold text-gold text-center mb-6">
{mode === "login" ? "Welcome Back" : "Create Account"}
</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{mode === "register" && (
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="px-4 py-3 bg-bg border border-gold/10 rounded-xl text-ink placeholder-dim focus:outline-none focus:border-gold/40 transition-colors"
/>
)}
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="px-4 py-3 bg-bg border border-gold/10 rounded-xl text-ink placeholder-dim focus:outline-none focus:border-gold/40 transition-colors"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="px-4 py-3 bg-bg border border-gold/10 rounded-xl text-ink placeholder-dim focus:outline-none focus:border-gold/40 transition-colors"
/>
{error && (
<p className="text-rust text-sm text-center">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-br from-gold to-amber-600 hover:from-gold-glow hover:to-gold text-bg font-semibold rounded-xl shadow-md hover:shadow-lg hover:shadow-gold/30 transition-all duration-300 active:scale-95 disabled:opacity-50"
>
{loading ? "..." : mode === "login" ? "Login" : "Register"}
</button>
</form>
<p className="text-dim text-sm text-center mt-6">
{mode === "login" ? "Don't have an account?" : "Already have an account?"}{" "}
<button
type="button"
onClick={() => { setMode(mode === "login" ? "register" : "login"); setError(""); }}
className="text-gold hover:text-gold-glow transition-colors cursor-pointer"
>
{mode === "login" ? "Register" : "Login"}
</button>
</p>
</div>
</div>
);
}
+10 -24
View File
@@ -1,31 +1,17 @@
"use client";
import { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import type { DiceRollerHandle, RollResult } from "@/components/DiceRoller";
import { Button } from "@/components/DiceSelector";
const DiceRoller = dynamic(() => import("@/components/DiceRoller"), {
ssr: false,
});
import MenuButton from "@/components/MenuButton";
export default function Home() {
const rollerRef = useRef<DiceRollerHandle>(null);
const [savedRolls, setSavedRolls] = useState([])
return (
<>
<div className="relative bg-bg w-screen h-screen">
<div className="absolute left-0 bottom-0">
<Button rollerRef={rollerRef} />
<div className="flex flex-col min-h-[calc(100vh-4rem)]">
<main className="flex-1 flex flex-col items-center justify-center gap-8 px-4">
<div className="text-center space-y-2">
<h1 className="text-3xl font-bold text-ink">Choose a tool</h1>
<p className="text-sm text-neutral-400">Player tools available now. DM tools coming later.</p>
</div>
<div className="absolute right-0 top-0">
{savedRolls.map((roll, i) => (
<div key={i}>test</div>
))}
<div className="flex flex-col sm:flex-row flex-wrap justify-center gap-4">
<MenuButton text="Characters" url="/characters" />
</div>
</div>
<DiceRoller ref={rollerRef} />
</>
</main>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import {
createContext,
useContext,
useRef,
useCallback,
type ReactNode,
} from "react";
import dynamic from "next/dynamic";
import type { DiceRollerHandle, RollOutcome } from "./DiceRoller";
const DiceRoller = dynamic(() => import("./DiceRoller"), { ssr: false });
interface DiceContextValue {
rollerRef: React.RefObject<DiceRollerHandle | null>;
onRollComplete: (callback: (outcome: RollOutcome) => void) => () => void;
}
const DiceContext = createContext<DiceContextValue | null>(null);
export function useDiceContext() {
const ctx = useContext(DiceContext);
if (!ctx) throw new Error("useDiceContext must be used within DiceProvider");
return ctx;
}
export function DiceProvider({ children }: { children: ReactNode }) {
const rollerRef = useRef<DiceRollerHandle>(null);
const listenersRef = useRef(new Set<(outcome: RollOutcome) => void>());
const broadcast = useCallback((outcome: RollOutcome) => {
listenersRef.current.forEach((cb) => cb(outcome));
}, []);
const onRollComplete = useCallback(
(callback: (outcome: RollOutcome) => void) => {
listenersRef.current.add(callback);
return () => {
listenersRef.current.delete(callback);
};
},
[]
);
return (
<DiceContext.Provider value={{ rollerRef, onRollComplete }}>
<div className="relative z-0 flex-1 flex flex-col">{children}</div>
<div className="fixed inset-0 z-[9999] pointer-events-none">
<DiceRoller ref={rollerRef} onRollComplete={broadcast} />
</div>
</DiceContext.Provider>
);
}
+21 -5
View File
@@ -1,12 +1,17 @@
'use client';
import React, { forwardRef, useImperativeHandle } from 'react';
import React, { forwardRef, useCallback, useImperativeHandle } from 'react';
import { useDiceRoller, RollResult } from '../hooks/useDiceRoller';
export type { RollResult };
export interface RollOutcome {
label: string;
roll: RollResult[];
}
export interface DiceRollerHandle {
roll(pool: Record<number, number>): Promise<RollResult[]>;
roll(pool: Record<number, number>, label: string): Promise<RollOutcome>;
isReady: boolean;
isRolling: boolean;
}
@@ -14,20 +19,31 @@ export interface DiceRollerHandle {
interface DiceRollerProps {
id?: string;
className?: string;
onRollComplete?: (outcome: RollOutcome) => void;
}
export const DiceRoller = forwardRef<DiceRollerHandle, DiceRollerProps>(
({ id, className }, ref) => {
({ id, className, onRollComplete }, ref) => {
const { mountRef, rollDice, isReady, isRolling } = useDiceRoller();
const wrappedRoll = useCallback(
async (pool: Record<number, number>, label: string) => {
const results = await rollDice(pool);
const outcome: RollOutcome = { label, roll: results };
onRollComplete?.(outcome);
return outcome;
},
[rollDice, onRollComplete]
);
useImperativeHandle(
ref,
() => ({
roll: rollDice,
roll: wrappedRoll,
isReady,
isRolling,
}),
[rollDice, isReady, isRolling]
[wrappedRoll, isReady, isRolling]
);
return <div ref={mountRef} id={id || 'scene'} className={className} />;
+6 -4
View File
@@ -7,7 +7,7 @@ import DiceD12Icon from '@iconify-react/mdi/dice-d12';
import DiceD20Icon from '@iconify-react/mdi/dice-d20';
import { RefObject, useState } from 'react';
import { DiceRollerHandle } from './DiceRoller';
import { div } from 'three/tsl';
import { useRollHistory } from './RollHistoryContext';
const diceIcons = {
4: DiceD4Icon,
@@ -23,6 +23,7 @@ interface ButtonProps {
}
export function Button({ rollerRef }: ButtonProps) {
const { roll } = useRollHistory();
const [open, setOpen] = useState(false);
const [dice, setDice] = useState<Record<number, number>>({
@@ -57,6 +58,8 @@ export function Button({ rollerRef }: ButtonProps) {
async function handleRoll() {
if (!rollerRef || !rollerRef.current) return;
const totalDice = Object.values(dice).reduce((a, b) => a + b, 0);
if (totalDice === 0) return;
const diceToRoll = dice;
@@ -69,12 +72,11 @@ export function Button({ rollerRef }: ButtonProps) {
20: 0
});
const res = rollerRef.current.roll(diceToRoll);
console.log(res);
await roll({ pool: diceToRoll, label: "Custom" });
}
return (
<div className='flex flex-row'>
<div className='flex flex-row-reverse items-end'>
<button onClick={handleOpen} className='group p-3 m-3 bg-gradient-to-br from-gold to-amber-600 hover:from-gold-glow hover:to-gold cursor-pointer rounded-full shadow-lg shadow-gold/20 hover:shadow-xl hover:shadow-gold/40 transition-all duration-300 active:scale-95'>
<DiceD20FA6 height="2.5em" className="text-bg group-hover:scale-110 transition-transform duration-300" />
</button>
+17
View File
@@ -0,0 +1,17 @@
import Link from "next/link";
interface MenuButtonProps {
text: string;
url: string;
}
export default function MenuButton({ text, url }: MenuButtonProps) {
return (
<Link
href={url}
className="block w-64 p-6 bg-surface border border-gold/20 rounded-2xl text-center text-ink text-xl font-bold shadow-lg hover:bg-gold/15 hover:border-gold/40 hover:shadow-xl hover:shadow-gold/20 transition-all duration-300 active:scale-95"
>
{text}
</Link>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useSession } from "next-auth/react";
import UserIcon from "@iconify-react/fa6-solid/user";
import UserSidebar from "./UserSidebar";
export default function Navbar() {
const { data: session, status } = useSession();
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<>
<nav className="flex items-center justify-between px-6 py-3 bg-surface/80 backdrop-blur-sm border-b border-gold/10">
<Link href="/" className="text-gold font-bold text-lg hover:text-gold-glow transition-colors">
D&amp;D Extended
</Link>
{status === "authenticated" ? (
<button
onClick={() => setSidebarOpen(true)}
className="w-10 h-10 rounded-full bg-gradient-to-br from-gold to-amber-600 flex items-center justify-center text-bg shadow-md hover:shadow-lg hover:shadow-gold/30 transition-all duration-300 active:scale-95 cursor-pointer overflow-hidden"
>
{session.user?.image ? (
<img
src={session.user.image}
alt="Profile"
className="w-full h-full object-cover"
/>
) : (
<UserIcon height="1.2em" />
)}
</button>
) : (
<Link
href="/login"
className="px-4 py-2 bg-gradient-to-br from-gold to-amber-600 hover:from-gold-glow hover:to-gold text-bg font-semibold rounded-xl shadow-md hover:shadow-lg hover:shadow-gold/30 transition-all duration-300 active:scale-95"
>
Login
</Link>
)}
</nav>
<UserSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
</>
);
}
+35 -2
View File
@@ -1,3 +1,36 @@
export default function RollCard({ roll }: { roll: unknown }) {
import type { RollResult } from "./DiceRoller";
import { useEffect, useState } from "react";
}
interface RollCardProps {
label: string;
roll: RollResult[];
onFadeOut: () => void;
}
export default function RollCard({ label, roll, onFadeOut }: RollCardProps) {
const [phase, setPhase] = useState<"in" | "out">("in");
useEffect(() => {
const fadeTimer = setTimeout(() => setPhase("out"), 3000);
const removeTimer = setTimeout(onFadeOut, 4000);
return () => {
clearTimeout(fadeTimer);
clearTimeout(removeTimer);
};
}, [onFadeOut]);
const total = roll.reduce((sum, r) => sum + r.value, 0);
return (
<div
className={`p-3 bg-surface border border-gold/20 rounded-2xl shadow-lg ${phase === "in"
? "animate-[cardIn_0.3s_ease-out_forwards]"
: "animate-[cardOut_0.5s_ease-in_forwards]"
}`}
>
<div className="text-dim text text-lg">
Rolled <span className="font-bold text-ink">{label}</span> <span className="text-gold font-bold">| {total}</span>
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
import { useDiceContext } from "./DiceContext";
import type { RollOutcome } from "./DiceRoller";
export interface RollEntry {
id: number;
label: string;
timestamp: number;
dice: Record<number, number>; // requested pool, e.g. { 20: 1 }
raw: { sides: number; value: number }[];
sum: number; // sum of raw dice values
modifier: number;
total: number; // sum + modifier
advantage?: "none" | "adv" | "dis";
}
interface RollHistoryContextValue {
history: RollEntry[];
toasts: RollEntry[];
dismissToast: (id: number) => void;
roll: (opts: {
pool: Record<number, number>;
label: string;
modifier?: number;
advantage?: "none" | "adv" | "dis";
}) => Promise<RollEntry | null>;
clear: () => void;
open: boolean;
setOpen: (open: boolean) => void;
}
const Ctx = createContext<RollHistoryContextValue | null>(null);
export function useRollHistory() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useRollHistory must be used within RollHistoryProvider");
return ctx;
}
let nextId = 1;
export function RollHistoryProvider({ children }: { children: ReactNode }) {
const dice = useDiceContext();
const [history, setHistory] = useState<RollEntry[]>([]);
const [toasts, setToasts] = useState<RollEntry[]>([]);
const [open, setOpen] = useState(false);
const dismissToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const roll = useCallback<RollHistoryContextValue["roll"]>(
async ({ pool, label, modifier = 0, advantage = "none" }) => {
const roller = dice.rollerRef.current;
if (!roller) return null;
// For advantage/disadvantage on a single d20, roll twice and pick.
const effectivePool = { ...pool };
const isD20Only =
Object.keys(pool).length === 1 &&
pool[20] === 1 &&
advantage !== "none";
if (isD20Only) effectivePool[20] = 2;
const outcome: RollOutcome = await roller.roll(effectivePool, label);
let raw = outcome.roll.map((r) => ({ sides: r.sides, value: r.value }));
// Apply advantage/disadvantage: pick higher/lower d20, keep others.
if (isD20Only) {
const d20s = raw.filter((r) => r.sides === 20).map((r) => r.value);
const pickedValue = advantage === "adv" ? Math.max(...d20s) : Math.min(...d20s);
raw = [{ sides: 20, value: pickedValue }];
}
const sum = raw.reduce((a, r) => a + r.value, 0);
const entry: RollEntry = {
id: nextId++,
label,
timestamp: Date.now(),
dice: pool,
raw,
sum,
modifier,
total: sum + modifier,
advantage,
};
setHistory((prev) => [entry, ...prev].slice(0, 100));
setToasts((prev) => [entry, ...prev].slice(0, 4));
return entry;
},
[dice.rollerRef]
);
const clear = useCallback(() => setHistory([]), []);
return (
<Ctx.Provider value={{ history, toasts, dismissToast, roll, clear, open, setOpen }}>
{children}
</Ctx.Provider>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useRollHistory } from "./RollHistoryContext";
function titleCase(s: string): string {
return s.replace(/\b\w/g, (c) => c.toUpperCase());
}
function timeAgo(ts: number) {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 5) return "just now";
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
return `${Math.floor(s / 3600)}h ago`;
}
export default function RollHistorySidebar() {
const { history, open, setOpen } = useRollHistory();
return (
<>
{/* Toggle handle — always visible on right edge */}
<button
onClick={() => setOpen(!open)}
className="fixed right-0 top-1/2 -translate-y-1/2 z-[10001] px-2 py-4 bg-surface border border-r-0 border-gold/40 rounded-l-lg text-gold hover:bg-gold/10 transition"
aria-label="Toggle roll history"
>
<span className="text-xs writing-mode-vertical" style={{ writingMode: "vertical-rl" }}>
Rolls {history.length > 0 && `(${history.length})`}
</span>
</button>
{/* Panel */}
<aside
className={`fixed right-0 top-0 h-screen w-80 z-[10002] bg-surface border-l border-gold/30 shadow-2xl transform transition-transform ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-gold/20">
<h2 className="text-sm font-bold text-gold uppercase tracking-wide">Roll History</h2>
<button
onClick={() => setOpen(false)}
className="text-neutral-400 hover:text-neutral-100 text-2xl leading-none"
>
×
</button>
</div>
<div className="overflow-y-auto h-[calc(100vh-3.5rem)] p-3 space-y-2">
{history.length === 0 && (
<p className="text-xs text-neutral-500 italic text-center py-8">
No rolls yet. Click any ability, save, or skill on the sheet.
</p>
)}
{history.map((r) => (
<div key={r.id} className="p-3 border border-neutral-700 rounded bg-neutral-950/50">
<div className="flex items-start justify-between gap-2">
<div className="text-sm font-medium text-ink truncate">{titleCase(r.label)}</div>
<div className="text-2xl font-bold text-gold leading-none">{r.total}</div>
</div>
<div className="mt-1 text-xs text-neutral-400">
{r.raw.map((d, i) => (
<span key={i}>
<span className={d.value === d.sides ? "text-emerald-400" : d.value === 1 ? "text-red-400" : ""}>
{d.value}
</span>
<span className="text-neutral-600"> (d{d.sides})</span>
{i < r.raw.length - 1 && <span className="text-neutral-600"> + </span>}
</span>
))}
{r.modifier !== 0 && (
<>
<span className="text-neutral-600"> {r.modifier >= 0 ? "+" : ""}</span>
<span>{r.modifier}</span>
</>
)}
{r.advantage && r.advantage !== "none" && (
<span className="ml-2 text-[10px] uppercase text-amber-400">{r.advantage}</span>
)}
</div>
<div className="mt-1 text-[10px] text-neutral-500">{timeAgo(r.timestamp)}</div>
</div>
))}
</div>
</aside>
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
import { useRollHistory, type RollEntry } from "./RollHistoryContext";
function titleCase(s: string): string {
return s.replace(/\b\w/g, (c) => c.toUpperCase());
}
export default function RollToasts() {
const { toasts, dismissToast } = useRollHistory();
return (
<div className="fixed top-16 right-4 z-[10001] flex flex-col gap-2 pointer-events-none w-72">
{toasts.map((t) => (
<ToastCard key={t.id} entry={t} onDismiss={() => dismissToast(t.id)} />
))}
</div>
);
}
function ToastCard({ entry, onDismiss }: { entry: RollEntry; onDismiss: () => void }) {
const [phase, setPhase] = useState<"in" | "out">("in");
useEffect(() => {
const fadeTimer = setTimeout(() => setPhase("out"), 3500);
const removeTimer = setTimeout(onDismiss, 4200);
return () => {
clearTimeout(fadeTimer);
clearTimeout(removeTimer);
};
}, [onDismiss]);
return (
<div
className={`p-3 bg-surface border border-gold/30 rounded-xl shadow-xl pointer-events-auto ${
phase === "in"
? "animate-[cardIn_0.25s_ease-out_forwards]"
: "animate-[cardOut_0.7s_ease-in_forwards]"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="text-sm font-medium text-ink truncate">{titleCase(entry.label)}</div>
<div className="text-2xl font-bold text-gold leading-none">{entry.total}</div>
</div>
<div className="mt-1 text-xs text-neutral-400 truncate">
{entry.raw.map((d, i) => (
<span key={i}>
<span
className={
d.value === d.sides
? "text-emerald-400"
: d.value === 1 && d.sides === 20
? "text-red-400"
: ""
}
>
{d.value}
</span>
<span className="text-neutral-600">(d{d.sides})</span>
{i < entry.raw.length - 1 && <span className="text-neutral-600"> + </span>}
</span>
))}
{entry.modifier !== 0 && (
<>
<span className="text-neutral-600"> {entry.modifier >= 0 ? "+" : ""}</span>
<span>{entry.modifier}</span>
</>
)}
{entry.advantage && entry.advantage !== "none" && (
<span className="ml-2 text-[10px] uppercase text-amber-400">{entry.advantage}</span>
)}
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useSession, signOut } from "next-auth/react";
import UserIcon from "@iconify-react/fa6-solid/user";
import XmarkIcon from "@iconify-react/fa6-solid/xmark";
import RightFromBracketIcon from "@iconify-react/fa6-solid/right-from-bracket";
interface UserSidebarProps {
isOpen: boolean;
onClose: () => void;
}
export default function UserSidebar({ isOpen, onClose }: UserSidebarProps) {
const { data: session } = useSession();
return (
<>
{/* Backdrop */}
<div
className={`fixed inset-0 z-40 bg-black/50 transition-opacity duration-300 ${
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
{/* Sidebar panel */}
<div
className={`fixed top-0 right-0 z-50 h-full w-80 bg-surface border-l border-gold/10 shadow-2xl flex flex-col transition-transform duration-300 ease-in-out ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gold/10">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-gold to-amber-600 flex items-center justify-center text-bg shrink-0 overflow-hidden">
{session?.user?.image ? (
<img
src={session.user.image}
alt="Profile"
className="w-full h-full object-cover"
/>
) : (
<UserIcon height="1.2em" />
)}
</div>
<div className="min-w-0">
<p className="text-ink font-semibold text-sm truncate">
{session?.user?.name || "User"}
</p>
<p className="text-dim text-xs truncate">
{session?.user?.email || ""}
</p>
</div>
</div>
<button
onClick={onClose}
className="text-dim hover:text-ink transition-colors cursor-pointer p-1"
>
<XmarkIcon height="1.2em" />
</button>
</div>
{/* Body — placeholder for future user options */}
<div className="flex-1 px-5 py-4" />
{/* Logout button */}
<div className="px-5 py-4 border-t border-gold/10">
<button
onClick={() => signOut()}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-rust/80 hover:bg-rust text-ink font-semibold rounded-xl transition-all duration-300 active:scale-95 cursor-pointer"
>
<RightFromBracketIcon height="1.1em" />
Logout
</button>
</div>
</div>
</>
);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { useSession } from "next-auth/react";
export function useIsAdmin(): boolean {
const { data } = useSession();
return data?.user?.isAdmin === true;
}
+311
View File
@@ -0,0 +1,311 @@
import {
Ability,
ABILITIES,
AbilityScores,
AppliedFeature,
Character,
Feature,
Skill,
SKILL_ABILITY,
} from "@/types/content";
import { AdvantageScope, Effect } from "@/types/core";
import { getAbilityModifier, getCharacterLevel, getProficiencyBonus } from "./dndHelpers";
export interface ResolvedCharacter {
base: Character;
abilities: AbilityScores;
speed: number;
hpMaxBonus: number;
acBonus: number;
initiativeBonus: number;
savingThrowProficiencies: Set<Ability>;
skillProficiencies: Set<Skill>;
skillExpertise: Set<Skill>;
toolExpertise: Set<string>;
armorProficiencies: Set<string>;
weaponProficiencies: Set<string>;
toolProficiencies: Set<string>;
languages: Set<string>;
resistances: Set<string>;
immunities: Set<string>;
vulnerabilities: Set<string>;
grantedSpells: Set<string>;
grantedFeats: Set<string>;
extraAttacks: number;
advantages: { sourceName: string; sourceCategory: string; scope: AdvantageScope; condition?: string }[];
disadvantages: { sourceName: string; sourceCategory: string; scope: AdvantageScope; condition?: string }[];
actions: { sourceName: string; sourceCategory: string; note: string }[];
bonusActions: { sourceName: string; sourceCategory: string; note: string }[];
reactions: { sourceName: string; sourceCategory: string; note: string }[];
notes: { sourceName: string; sourceCategory: string; note: string }[];
}
function applyEffect(
r: ResolvedCharacter,
e: Effect,
chosen?: string[],
featureName?: string,
sourceCategory?: string,
) {
const t = e.target;
const attribution = {
sourceName: featureName ?? "",
sourceCategory: sourceCategory ?? "custom",
};
const delta =
e.operation === "decrease" ? -(e.value ?? 0) : e.value ?? 0;
switch (t.kind) {
case "ability":
if (e.operation === "set" && e.value !== undefined) {
r.abilities[t.ability] = e.value;
} else {
r.abilities[t.ability] += delta;
}
return;
case "abilityScoreChoice": {
const counts: Partial<Record<Ability, number>> = {};
for (const raw of (chosen ?? []).slice(0, t.points)) {
const a = raw as Ability;
if (!(a in r.abilities)) continue;
counts[a] = (counts[a] ?? 0) + 1;
}
for (const a of Object.keys(counts) as Ability[]) {
const amt = Math.min(counts[a] ?? 0, t.maxPerAbility);
const nextScore = r.abilities[a] + amt;
r.abilities[a] = t.cap !== undefined ? Math.min(nextScore, t.cap) : nextScore;
}
return;
}
case "speed":
if (e.operation === "set" && e.value !== undefined) r.speed = e.value;
else r.speed += delta;
return;
case "hpMax":
r.hpMaxBonus += delta;
return;
case "armorClass":
r.acBonus += delta;
return;
case "initiative":
r.initiativeBonus += delta;
return;
case "savingThrowProficiency":
r.savingThrowProficiencies.add(t.ability);
return;
case "skillProficiency":
r.skillProficiencies.add(t.skill);
return;
case "skillProficiencyChoice":
for (const s of (chosen ?? []).slice(0, t.count)) {
if (t.from.includes(s as Skill)) r.skillProficiencies.add(s as Skill);
}
return;
case "skillExpertise":
r.skillExpertise.add(t.skill);
r.skillProficiencies.add(t.skill);
return;
case "toolExpertise":
r.toolExpertise.add(t.tool);
r.toolProficiencies.add(t.tool);
return;
case "expertiseChoice":
for (const raw of (chosen ?? []).slice(0, t.count)) {
if (raw.startsWith("skill:")) {
const skill = raw.slice(6) as Skill;
const eligible =
t.skillPool === "characterSkills"
? r.skillProficiencies.has(skill)
: t.skillPool.includes(skill);
if (eligible) {
r.skillExpertise.add(skill);
r.skillProficiencies.add(skill);
}
} else if (raw.startsWith("tool:")) {
const tool = raw.slice(5);
if (t.toolPool.includes(tool)) {
r.toolExpertise.add(tool);
r.toolProficiencies.add(tool);
}
}
}
return;
case "armorProficiency":
r.armorProficiencies.add(t.value);
return;
case "weaponProficiency":
r.weaponProficiencies.add(t.value);
return;
case "toolProficiency":
r.toolProficiencies.add(t.value);
return;
case "language":
r.languages.add(t.value);
return;
case "resistance":
r.resistances.add(t.damageType);
return;
case "immunity":
r.immunities.add(t.damageType);
return;
case "vulnerability":
r.vulnerabilities.add(t.damageType);
return;
case "grantSpell":
r.grantedSpells.add(t.spellId);
return;
case "grantFeat":
r.grantedFeats.add(t.featId);
return;
case "extraAttack":
r.extraAttacks += t.count;
return;
case "advantage":
r.advantages.push({ ...attribution, scope: t.scope, condition: t.condition });
return;
case "disadvantage":
r.disadvantages.push({ ...attribution, scope: t.scope, condition: t.condition });
return;
case "action":
r.actions.push({ ...attribution, note: t.note });
return;
case "bonusAction":
r.bonusActions.push({ ...attribution, note: t.note });
return;
case "reaction":
r.reactions.push({ ...attribution, note: t.note });
return;
case "custom":
r.notes.push({ ...attribution, note: t.note });
return;
}
}
export function resolveCharacter(
character: Character,
featureDict: Record<string, Feature>
): ResolvedCharacter {
const r: ResolvedCharacter = {
base: character,
abilities: { ...character.abilities },
speed: 30,
hpMaxBonus: 0,
acBonus: 0,
initiativeBonus: 0,
savingThrowProficiencies: new Set(character.savingThrowProficiencies ?? []),
skillProficiencies: new Set(character.skillProficiencies ?? []),
skillExpertise: new Set(character.skillExpertise ?? []),
toolExpertise: new Set(),
armorProficiencies: new Set(character.proficiencies?.armor ?? []),
weaponProficiencies: new Set(character.proficiencies?.weapons ?? []),
toolProficiencies: new Set(character.proficiencies?.tools ?? []),
languages: new Set(character.proficiencies?.languages ?? []),
resistances: new Set(),
immunities: new Set(),
vulnerabilities: new Set(),
grantedSpells: new Set(),
grantedFeats: new Set(character.feats ?? []),
extraAttacks: 0,
advantages: [],
disadvantages: [],
actions: [],
bonusActions: [],
reactions: [],
notes: [],
};
const totalLevel = getCharacterLevel(character);
const classLevels: Record<string, number> = {};
for (const c of character.classes ?? []) classLevels[c.classId] = c.level;
for (const applied of character.appliedFeatures ?? []) {
const f = featureDict[applied.featureId];
if (!f) continue;
const relevantLevel =
applied.source === "class" ? classLevels[applied.sourceId] ?? 0 : totalLevel;
const chosen = character.featureChoices?.[applied.featureId];
for (const eff of f.effects ?? []) {
if ((eff.level ?? 1) > relevantLevel) continue;
applyEffect(r, eff, chosen, f.name, applied.source);
}
}
return r;
}
export function abilityModifier(r: ResolvedCharacter, ability: Ability): number {
return getAbilityModifier(r.abilities[ability]);
}
export function savingThrow(r: ResolvedCharacter, ability: Ability): number {
const mod = abilityModifier(r, ability);
const prof = r.savingThrowProficiencies.has(ability)
? getProficiencyBonus(getCharacterLevel(r.base))
: 0;
return mod + prof;
}
function scopeMatches(scope: AdvantageScope, query: AdvantageScope): boolean {
if (scope.on !== query.on) return false;
if (scope.on === "skill" && query.on === "skill") return scope.skill === query.skill;
if ((scope.on === "save" || scope.on === "check") && (query.on === "save" || query.on === "check")) {
const s = scope as { ability?: Ability };
const q = query as { ability?: Ability };
return s.ability === undefined || s.ability === q.ability;
}
return true;
}
export function hasAdvantage(r: ResolvedCharacter, query: AdvantageScope): { yes: boolean; conditions: string[] } {
const matches = r.advantages.filter((a) => scopeMatches(a.scope, query));
return { yes: matches.length > 0, conditions: matches.map((m) => m.condition ?? "").filter(Boolean) };
}
export function hasDisadvantage(r: ResolvedCharacter, query: AdvantageScope): { yes: boolean; conditions: string[] } {
const matches = r.disadvantages.filter((a) => scopeMatches(a.scope, query));
return { yes: matches.length > 0, conditions: matches.map((m) => m.condition ?? "").filter(Boolean) };
}
export function skillBonus(r: ResolvedCharacter, skill: Skill): number {
const mod = abilityModifier(r, SKILL_ABILITY[skill]);
const pb = getProficiencyBonus(getCharacterLevel(r.base));
if (r.skillExpertise.has(skill)) return mod + pb * 2;
if (r.skillProficiencies.has(skill)) return mod + pb;
return mod;
}
/**
* Build applied-features list from the character's selected species/classes/background.
* Caller resolves feature docs separately.
*/
export function collectAppliedFeatures(input: {
speciesFeatureIds: string[];
backgroundFeatureIds: string[];
classLevels: { classId: string; featureIds: string[]; level: number }[];
featIds?: string[];
speciesId: string;
backgroundId?: string;
}): AppliedFeature[] {
const out: AppliedFeature[] = [];
for (const fid of input.speciesFeatureIds) {
out.push({ featureId: fid, source: "species", sourceId: input.speciesId });
}
if (input.backgroundId) {
for (const fid of input.backgroundFeatureIds) {
out.push({ featureId: fid, source: "background", sourceId: input.backgroundId });
}
}
for (const c of input.classLevels) {
for (const fid of c.featureIds) {
out.push({ featureId: fid, source: "class", sourceId: c.classId, level: c.level });
}
}
for (const featId of input.featIds ?? []) {
out.push({ featureId: featId, source: "feat", sourceId: featId });
}
return out;
}
export function emptyAbilities(): AbilityScores {
return ABILITIES.reduce((acc, a) => ({ ...acc, [a]: 8 }), {} as AbilityScores);
}
+53
View File
@@ -0,0 +1,53 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import clientPromise from "./mongodb";
export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true,
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const client = await clientPromise;
const db = client.db("dndextended");
const user = await db.collection("users").findOne({ email: credentials.email });
if (!user) return null;
const valid = await compare(credentials.password as string, user.password);
if (!valid) return null;
return {
id: user._id.toString(),
name: user.name,
email: user.email,
isAdmin: user.isAdmin === true,
};
},
}),
],
session: { strategy: "jwt" },
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.isAdmin = (user as { isAdmin?: boolean }).isAdmin === true;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
session.user.isAdmin = token.isAdmin === true;
}
return session;
},
},
pages: {
signIn: "/login",
},
});
+27
View File
@@ -0,0 +1,27 @@
import type { Feature } from "@/types/content";
/**
* After a parent (species/class/background) saves, adopt any attached orphan features.
* Already-parented features are left alone (they belong to a different parent).
*/
export async function claimFeaturesForParent(input: {
parentType: "species" | "class" | "background" | "subclass";
parentId: string;
featureIds: string[];
featureDict: Record<string, Feature>;
}) {
const patches = input.featureIds
.map((id) => input.featureDict[id])
.filter((f): f is Feature => !!f)
.filter((f) => !f.parentType && !f.parentId);
await Promise.all(
patches.map((f) =>
fetch(`/api/features/${encodeURIComponent(f.id)}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ parentType: input.parentType, parentId: input.parentId }),
})
)
);
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import clientPromise from "./mongodb";
import { auth } from "./auth";
const DB = "dndextended";
export async function listContent(collection: string, req: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Not Authenticated" }, { status: 401 });
const userId = session.user.id;
const url = new URL(req.url);
const scope = url.searchParams.get("scope") ?? "all";
const filter =
scope === "mine"
? { creatorId: userId }
: scope === "public"
? { visibility: "public" }
: { $or: [{ visibility: "public" }, { creatorId: userId }] };
const client = await clientPromise;
const results = await client.db(DB).collection(collection).find(filter).toArray();
return NextResponse.json({ results });
}
export async function createContent(collection: string, req: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Not Authenticated" }, { status: 401 });
const userId = session.user.id;
const body = await req.json();
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
if (!body.name || typeof body.name !== "string") {
return NextResponse.json({ error: "name required" }, { status: 400 });
}
const isAdmin = session.user.isAdmin === true;
const requestedPublic = body.visibility === "public";
const VALID_RULESETS = ["5e", "5.5e", "legacy", "homebrew"];
const requestedRuleset = VALID_RULESETS.includes(body.ruleset) ? body.ruleset : "5e";
const doc = {
...body,
id: body.id ?? randomUUID(),
creatorId: userId,
visibility: requestedPublic && isAdmin ? "public" : "private",
ruleset: isAdmin ? requestedRuleset : "homebrew",
createdAt: new Date().toISOString(),
};
const client = await clientPromise;
await client.db(DB).collection(collection).insertOne(doc);
return NextResponse.json({ result: doc }, { status: 201 });
}
+42
View File
@@ -0,0 +1,42 @@
import { MongoClient } from "mongodb";
/**
* Lazy MongoClient promise. We don't touch `process.env.MONGODB_URI` at module
* import time so `next build` / page-data collection doesn't crash when the env
* isn't populated (e.g. inside a Docker build with no runtime env).
*/
declare global {
var _mongoClientPromise: Promise<MongoClient> | undefined;
}
function createClientPromise(): Promise<MongoClient> {
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error("MONGODB_URI is not set");
}
const client = new MongoClient(uri, {});
return client.connect();
}
function getClientPromise(): Promise<MongoClient> {
if (process.env.NODE_ENV === "development") {
if (!globalThis._mongoClientPromise) {
globalThis._mongoClientPromise = createClientPromise();
}
return globalThis._mongoClientPromise;
}
if (!globalThis._mongoClientPromise) {
globalThis._mongoClientPromise = createClientPromise();
}
return globalThis._mongoClientPromise;
}
const clientPromise: Promise<MongoClient> = {
then: (onFulfilled, onRejected) => getClientPromise().then(onFulfilled, onRejected),
catch: (onRejected) => getClientPromise().catch(onRejected),
finally: (onFinally) => getClientPromise().finally(onFinally),
[Symbol.toStringTag]: "Promise",
} as Promise<MongoClient>;
export default clientPromise;
+290 -47
View File
@@ -1,4 +1,4 @@
import { Dice, ExpandedDice } from "./core";
import { Dice, Effect, ExpandedDice } from "./core";
export type Ability =
| "strength"
@@ -8,74 +8,279 @@ export type Ability =
| "wisdom"
| "charisma";
export const ABILITIES: Ability[] = [
"strength",
"dexterity",
"constitution",
"intelligence",
"wisdom",
"charisma",
];
export type Skill =
| "acrobatics"
| "animalHandling"
| "arcana"
| "athletics"
| "deception"
| "history"
| "insight"
| "intimidation"
| "investigation"
| "medicine"
| "nature"
| "perception"
| "performance"
| "persuasion"
| "religion"
| "sleightOfHand"
| "stealth"
| "survival";
export const SKILL_ABILITY: Record<Skill, Ability> = {
acrobatics: "dexterity",
animalHandling: "wisdom",
arcana: "intelligence",
athletics: "strength",
deception: "charisma",
history: "intelligence",
insight: "wisdom",
intimidation: "charisma",
investigation: "intelligence",
medicine: "wisdom",
nature: "intelligence",
perception: "wisdom",
performance: "charisma",
persuasion: "charisma",
religion: "intelligence",
sleightOfHand: "dexterity",
stealth: "dexterity",
survival: "wisdom",
};
export type Size = "tiny" | "small" | "medium" | "large" | "huge" | "gargantuan";
export type CreatureType =
| "aberration"
| "beast"
| "celestial"
| "construct"
| "dragon"
| "elemental"
| "fey"
| "fiend"
| "giant"
| "humanoid"
| "monstrosity"
| "ooze"
| "plant"
| "undead";
export type Alignment =
| "lawfulGood" | "neutralGood" | "chaoticGood"
| "lawfulNeutral" | "trueNeutral" | "chaoticNeutral"
| "lawfulEvil" | "neutralEvil" | "chaoticEvil";
export type RollMethod = "rolled" | "average";
export type Visibility = "private" | "public";
export type Ruleset = "5e" | "5.5e" | "legacy" | "homebrew";
export const RULESETS: Ruleset[] = ["5e", "5.5e", "legacy", "homebrew"];
export const RULESET_LABELS: Record<Ruleset, string> = {
"5e": "5e",
"5.5e": "5.5e",
legacy: "Legacy",
homebrew: "Homebrew",
};
export interface CustomContentMeta {
id: string;
creatorId: string;
visibility: Visibility;
createdAt: string;
}
export interface Feature {
id: string;
name: string;
description: string;
source: "species" | "class" | "background" | "feat" | "subclass" | "custom";
level?: number;
effects: Effect[];
/**
* Parent doc that owns this feature. If both are null the feature is a global orphan —
* shown to any builder as attachable. First attach claims it.
*/
parentType?: "species" | "class" | "background" | "subclass" | null;
parentId?: string | null;
creatorId?: string;
visibility?: Visibility;
ruleset?: Ruleset;
sourcebook?: string;
createdAt?: string;
}
export interface Species {
id: string;
name: string;
description?: string;
creatureType: CreatureType;
sizes: Size[];
speed: number;
featureIds: string[];
languages: string[];
creatorId?: string;
visibility?: Visibility;
ruleset?: Ruleset;
sourcebook?: string;
createdAt?: string;
}
export interface Background {
id: string;
name: string;
description?: string;
skillProficiencies: Skill[];
toolProficiencies: string[];
languages: string[];
featureIds: string[];
creatorId?: string;
visibility?: Visibility;
ruleset?: Ruleset;
sourcebook?: string;
createdAt?: string;
}
export interface SpellcastingProgressionRow {
level: number;
cantripsKnown: number;
spellsKnown: number;
/** Slots by spell level: [1st, 2nd, 3rd, 4th, 5th, 6th, 7th, 8th, 9th]. Trailing zeros optional. */
slots: number[];
}
export interface Spellcasting {
ability: Ability;
ritual?: boolean;
/** Optional: spell ids the caster may learn. Empty = any spell for this class. */
spellListIds?: string[];
progression?: SpellcastingProgressionRow[];
/** Legacy fields — prefer `progression`. */
slotsPerLevel?: Record<number, number[]>;
cantripsKnown?: number;
spellsKnown?: number;
}
export interface ClassLevelFeatures {
level: number;
featureIds: string[];
}
export interface Subclass {
id: string;
classId: string;
name: string;
description?: string;
levelFeatures: ClassLevelFeatures[];
spellcasting?: Spellcasting;
creatorId?: string;
visibility?: Visibility;
ruleset?: Ruleset;
sourcebook?: string;
createdAt?: string;
}
export interface ClassDef {
id: string;
name: string;
description?: string;
hitDice: ExpandedDice;
primaryAbility: Ability[];
savingThrowProficiencies: Ability[];
skillProficiencyOptions: { count: number; from: Skill[] };
armorProficiencies: string[];
weaponProficiencies: string[];
toolProficiencies: string[];
levelFeatures: ClassLevelFeatures[];
spellcasting?: Spellcasting;
creatorId?: string;
visibility?: Visibility;
ruleset?: Ruleset;
sourcebook?: string;
createdAt?: string;
}
export interface Spell {
id: string;
name: string;
level: number;
school: string;
castingTime: string;
range: string;
components: string;
duration: string;
description: string;
classIds?: string[];
creatorId?: string;
visibility?: Visibility;
ruleset?: Ruleset;
sourcebook?: string;
createdAt?: string;
}
export type ContentEntry = {
id: string;
type:
| "species"
| "class"
| "subclass"
| "spell"
| "item"
| "weapon"
| "armor"
| "feature"
| "background";
| "species"
| "class"
| "subclass"
| "spell"
| "item"
| "weapon"
| "armor"
| "feature"
| "background";
name: string;
data: unknown;
}
};
export type ContentPack = {
id: string;
name: string;
ownerId: string;
visibility: "private" | "public";
visibility: Visibility;
entries: ContentEntry[];
}
};
export type Armor = {
id: string;
name: string;
armorClass: number;
dexterityBonus:
| "full"
| "limited"
| "none";
dexterityBonus: "full" | "limited" | "none";
maxDexterity?: number;
shieldBonus?: number;
}
};
export type Weapon = {
id: string;
name: string;
damage: {
dice: Dice;
type: string;
};
properties: string[];
ability: {
default: Ability;
options: {
name: string;
ability: Ability;
requirement?: unknown; // To be implemented (requirement based on character opts, i.e. wizards use int, dwarves use char, etc.)
}[]
}
requirement?: unknown;
}[];
};
proficientRequired: boolean;
}
};
export type AbilityScores = {
strength: number;
@@ -84,56 +289,94 @@ export type AbilityScores = {
intelligence: number;
wisdom: number;
charisma: number;
}
};
/**
* Legacy alias — prefer ClassDef.
*/
export type Class = {
id: string;
name: string;
hitDice: ExpandedDice;
}
};
export type CharacterClass = {
classId: string;
level: number;
}
subclassId?: string;
};
export type CharacterHP = {
method: RollMethod;
max: number;
current?: number;
temporary?: number;
history: {
level: number;
classId: string;
roll?: number;
increase: number;
}[];
};
/**
* Points to a feature granted to a character, and where it came from.
* Lets us revoke feature effects when a class is delevelled or a species swapped.
*/
export interface AppliedFeature {
featureId: string;
source: "species" | "class" | "background" | "feat" | "custom";
sourceId: string;
level?: number;
}
export type Character = {
id: string;
name: string;
userId: string;
alignment?: Alignment;
speciesId: string;
backgroundId?: string;
classes: CharacterClass[];
hp: CharacterHP;
abilities: AbilityScores;
abilityMethod?: "standardArray" | "pointBuy" | "rolled" | "manual";
rulesets?: Ruleset[];
savingThrowProficiencies: Ability[];
skillProficiencies: Skill[];
skillExpertise: Skill[];
proficiencies: {
armor: string[];
weapons: string[];
tools: string[];
languages: string[];
};
feats: string[];
appliedFeatures: AppliedFeature[];
/**
* Player-made choices for features that offer "pick N from M" mechanics.
* Key = featureId. Value = chosen items (skill names, ability names, etc).
*/
featureChoices?: Record<string, string[]>;
inventory: string[];
equipped: {
armor?: string;
weapons: string[];
};
magic: {
spells: Record<string, string[]>; // { <level>: <spell_id>}
slots: Record<string, [number, number]> // { <level>: [<used>, <max>] }
}
spells: Record<string, string[]>;
slots: Record<string, [number, number]>;
};
contentSources: string[];
}
};
+80 -10
View File
@@ -1,26 +1,96 @@
import { Ability } from "./content";
import { Ability, Skill } from "./content";
export type Dice = Record<string, number>;
export type ExpandedDice = {
sides: number;
count: number;
}
};
export type AdvantageScope =
| { on: "attack" }
| { on: "save"; ability?: Ability }
| { on: "skill"; skill: Skill }
| { on: "check"; ability?: Ability };
export type EffectTarget =
| { kind: "ability"; ability: Ability }
| {
kind: "abilityScoreChoice";
/** Total points player distributes. */
points: number;
/** Max increase applied to any single ability. */
maxPerAbility: number;
/** Hard cap on the resulting ability score. Standard 5e ASI = 20. Omit for no cap. */
cap?: number;
}
| { kind: "speed" }
| { kind: "hpMax" }
| { kind: "armorClass" }
| { kind: "initiative" }
| { kind: "savingThrowProficiency"; ability: Ability }
| { kind: "skillProficiency"; skill: Skill }
| { kind: "skillProficiencyChoice"; count: number; from: Skill[] }
| { kind: "skillExpertise"; skill: Skill }
| { kind: "toolExpertise"; tool: string }
| {
kind: "expertiseChoice";
count: number;
/** "characterSkills" = any skill the character is proficient in at resolve time. */
skillPool: Skill[] | "characterSkills";
toolPool: string[];
}
| { kind: "armorProficiency"; value: string }
| { kind: "weaponProficiency"; value: string }
| { kind: "toolProficiency"; value: string }
| { kind: "language"; value: string }
| { kind: "resistance"; damageType: string }
| { kind: "immunity"; damageType: string }
| { kind: "vulnerability"; damageType: string }
| { kind: "grantSpell"; spellId: string }
| {
kind: "grantSpellcasting";
ability: Ability;
ritual?: boolean;
/** Optional list of spell ids to constrain choices. Empty = any spell available. */
spellListIds?: string[];
/** Progression table by character/class level. */
progression: {
level: number;
cantripsKnown: number;
spellsKnown: number;
slots: number[];
}[];
}
| { kind: "grantFeat"; featId: string }
| { kind: "extraAttack"; count: number }
| { kind: "grantSubclassChoice"; label?: string }
| { kind: "advantage"; scope: AdvantageScope; condition?: string }
| { kind: "disadvantage"; scope: AdvantageScope; condition?: string }
| { kind: "action"; note: string }
| { kind: "bonusAction"; note: string }
| { kind: "reaction"; note: string }
| { kind: "custom"; note: string };
export type Effect = {
target: string;
operation: "increase" | "decrease" | "roll";
value: number;
}
target: EffectTarget;
operation: "grant" | "increase" | "decrease" | "set";
value?: number;
/**
* Minimum level for this effect to activate.
* Class-source features check the character's level in that class.
* Species/background/feat features check total character level.
* Omit or set to 1 for always-on.
*/
level?: number;
};
export type AttackResult = {
ability: Ability;
attackBonus: number;
damage: {
dice: Dice;
modifier: number;
type: string;
}
}
};
};
+17
View File
@@ -0,0 +1,17 @@
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: { id: string; isAdmin?: boolean } & DefaultSession["user"];
}
interface User {
isAdmin?: boolean;
}
}
declare module "next-auth/jwt" {
interface JWT {
id?: string;
isAdmin?: boolean;
}
}