Compare commits
15 Commits
8ee55532f3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f3cd9919b | |||
| 13aae7fff8 | |||
| b33791103b | |||
| 0ac0b88f8c | |||
| 1f53b0767f | |||
| 7144ac8fed | |||
| 749dbcfe55 | |||
| 687b0e251a | |||
| d7c87bca5c | |||
| 6114d34c22 | |||
| 9adaca1c7d | |||
| a3ea76fdff | |||
| 1362f28585 | |||
| a6dfad20c2 | |||
| 275047fbfb |
@@ -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
|
||||||
@@ -39,3 +39,4 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
.aider*
|
||||||
|
|||||||
+20
@@ -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"]
|
||||||
@@ -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
@@ -1,7 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
devIndicators: false,
|
||||||
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+448
-28
@@ -8,18 +8,25 @@
|
|||||||
"name": "dndextended",
|
"name": "dndextended",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.5.7",
|
||||||
"@iconify-react/fa6-solid": "^1.0.2",
|
"@iconify-react/fa6-solid": "^1.0.2",
|
||||||
"@iconify-react/game-icons": "^1.0.3",
|
"@iconify-react/game-icons": "^1.0.3",
|
||||||
"@iconify-react/mdi": "^1.0.2",
|
"@iconify-react/mdi": "^1.0.2",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.6.1",
|
"@react-three/fiber": "^9.6.1",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"mongodb": "^7.5.0",
|
||||||
"next": "16.2.10",
|
"next": "16.2.10",
|
||||||
|
"next-auth": "^5.0.0-beta.31",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -42,6 +49,35 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -437,6 +473,30 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"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": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "9.39.4",
|
"version": "9.39.4",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
|
"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": "^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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -1158,6 +1328,15 @@
|
|||||||
"integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
|
"integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/@monogrid/gainmap-js": {
|
||||||
"version": "3.4.0",
|
"version": "3.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
|
||||||
@@ -1393,6 +1572,15 @@
|
|||||||
"node": ">=12.4.0"
|
"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": {
|
"node_modules/@react-three/drei": {
|
||||||
"version": "10.7.7",
|
"version": "10.7.7",
|
||||||
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
|
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
|
||||||
@@ -1488,6 +1676,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -1797,6 +1991,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/draco3d": {
|
||||||
"version": "1.4.10",
|
"version": "1.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
|
||||||
@@ -1888,12 +2089,27 @@
|
|||||||
"meshoptimizer": "~1.1.1"
|
"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": {
|
"node_modules/@types/webxr": {
|
||||||
"version": "0.5.24",
|
"version": "0.5.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.63.0",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz",
|
"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"
|
"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": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
@@ -2886,6 +3085,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/bidi-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
"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": "^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": {
|
"node_modules/buffer": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
@@ -3932,6 +4149,30 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"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": {
|
"node_modules/espree": {
|
||||||
"version": "10.4.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||||
@@ -4000,7 +4241,7 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
@@ -5031,6 +5272,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -5081,13 +5331,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"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": ">= 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": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -5588,6 +5837,65 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"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": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -5746,6 +6081,15 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -6040,6 +6384,25 @@
|
|||||||
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
|
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -6076,7 +6439,6 @@
|
|||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -6124,6 +6486,22 @@
|
|||||||
"react": "^19.2.4"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -6562,6 +6940,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -6924,6 +7311,18 @@
|
|||||||
"node": ">=8.0"
|
"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": {
|
"node_modules/troika-three-text": {
|
||||||
"version": "0.52.4",
|
"version": "0.52.4",
|
||||||
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
|
"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==",
|
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -7437,7 +7858,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
+8
-1
@@ -9,18 +9,25 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.5.7",
|
||||||
"@iconify-react/fa6-solid": "^1.0.2",
|
"@iconify-react/fa6-solid": "^1.0.2",
|
||||||
"@iconify-react/game-icons": "^1.0.3",
|
"@iconify-react/game-icons": "^1.0.3",
|
||||||
"@iconify-react/mdi": "^1.0.2",
|
"@iconify-react/mdi": "^1.0.2",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.6.1",
|
"@react-three/fiber": "^9.6.1",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"mongodb": "^7.5.0",
|
||||||
"next": "16.2.10",
|
"next": "16.2.10",
|
||||||
|
"next-auth": "^5.0.0-beta.31",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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'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' tools</code></li>
|
||||||
|
<li><code>disguise kit</code></li>
|
||||||
|
<li><code>herbalism kit</code></li>
|
||||||
|
<li><code>smith'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 "level" 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 "Lv" 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 & 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 "level" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -14,12 +14,20 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-y: scroll;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
overflow: hidden;
|
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
@@ -51,3 +59,25 @@ canvas {
|
|||||||
transform: scaleX(1) scaleY(1) translateY(-50%);
|
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
@@ -1,5 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
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";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -27,7 +33,18 @@ export default function RootLayout({
|
|||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
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>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+9
-23
@@ -1,31 +1,17 @@
|
|||||||
"use client";
|
import MenuButton from "@/components/MenuButton";
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const rollerRef = useRef<DiceRollerHandle>(null);
|
|
||||||
|
|
||||||
const [savedRolls, setSavedRolls] = useState([])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col min-h-[calc(100vh-4rem)]">
|
||||||
<div className="relative bg-bg w-screen h-screen">
|
<main className="flex-1 flex flex-col items-center justify-center gap-8 px-4">
|
||||||
<div className="absolute left-0 bottom-0">
|
<div className="text-center space-y-2">
|
||||||
<Button rollerRef={rollerRef} />
|
<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>
|
||||||
<div className="absolute right-0 top-0">
|
<div className="flex flex-col sm:flex-row flex-wrap justify-center gap-4">
|
||||||
{savedRolls.map((roll, i) => (
|
<MenuButton text="Characters" url="/characters" />
|
||||||
<div key={i}>test</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<DiceRoller ref={rollerRef} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { forwardRef, useImperativeHandle } from 'react';
|
import React, { forwardRef, useCallback, useImperativeHandle } from 'react';
|
||||||
import { useDiceRoller, RollResult } from '../hooks/useDiceRoller';
|
import { useDiceRoller, RollResult } from '../hooks/useDiceRoller';
|
||||||
|
|
||||||
export type { RollResult };
|
export type { RollResult };
|
||||||
|
|
||||||
|
export interface RollOutcome {
|
||||||
|
label: string;
|
||||||
|
roll: RollResult[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiceRollerHandle {
|
export interface DiceRollerHandle {
|
||||||
roll(pool: Record<number, number>): Promise<RollResult[]>;
|
roll(pool: Record<number, number>, label: string): Promise<RollOutcome>;
|
||||||
isReady: boolean;
|
isReady: boolean;
|
||||||
isRolling: boolean;
|
isRolling: boolean;
|
||||||
}
|
}
|
||||||
@@ -14,20 +19,31 @@ export interface DiceRollerHandle {
|
|||||||
interface DiceRollerProps {
|
interface DiceRollerProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
onRollComplete?: (outcome: RollOutcome) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DiceRoller = forwardRef<DiceRollerHandle, DiceRollerProps>(
|
export const DiceRoller = forwardRef<DiceRollerHandle, DiceRollerProps>(
|
||||||
({ id, className }, ref) => {
|
({ id, className, onRollComplete }, ref) => {
|
||||||
const { mountRef, rollDice, isReady, isRolling } = useDiceRoller();
|
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(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
roll: rollDice,
|
roll: wrappedRoll,
|
||||||
isReady,
|
isReady,
|
||||||
isRolling,
|
isRolling,
|
||||||
}),
|
}),
|
||||||
[rollDice, isReady, isRolling]
|
[wrappedRoll, isReady, isRolling]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <div ref={mountRef} id={id || 'scene'} className={className} />;
|
return <div ref={mountRef} id={id || 'scene'} className={className} />;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import DiceD12Icon from '@iconify-react/mdi/dice-d12';
|
|||||||
import DiceD20Icon from '@iconify-react/mdi/dice-d20';
|
import DiceD20Icon from '@iconify-react/mdi/dice-d20';
|
||||||
import { RefObject, useState } from 'react';
|
import { RefObject, useState } from 'react';
|
||||||
import { DiceRollerHandle } from './DiceRoller';
|
import { DiceRollerHandle } from './DiceRoller';
|
||||||
import { div } from 'three/tsl';
|
import { useRollHistory } from './RollHistoryContext';
|
||||||
|
|
||||||
const diceIcons = {
|
const diceIcons = {
|
||||||
4: DiceD4Icon,
|
4: DiceD4Icon,
|
||||||
@@ -23,6 +23,7 @@ interface ButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Button({ rollerRef }: ButtonProps) {
|
export function Button({ rollerRef }: ButtonProps) {
|
||||||
|
const { roll } = useRollHistory();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const [dice, setDice] = useState<Record<number, number>>({
|
const [dice, setDice] = useState<Record<number, number>>({
|
||||||
@@ -57,6 +58,8 @@ export function Button({ rollerRef }: ButtonProps) {
|
|||||||
|
|
||||||
async function handleRoll() {
|
async function handleRoll() {
|
||||||
if (!rollerRef || !rollerRef.current) return;
|
if (!rollerRef || !rollerRef.current) return;
|
||||||
|
const totalDice = Object.values(dice).reduce((a, b) => a + b, 0);
|
||||||
|
if (totalDice === 0) return;
|
||||||
|
|
||||||
const diceToRoll = dice;
|
const diceToRoll = dice;
|
||||||
|
|
||||||
@@ -69,12 +72,11 @@ export function Button({ rollerRef }: ButtonProps) {
|
|||||||
20: 0
|
20: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = rollerRef.current.roll(diceToRoll);
|
await roll({ pool: diceToRoll, label: "Custom" });
|
||||||
console.log(res);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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'>
|
<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" />
|
<DiceD20FA6 height="2.5em" className="text-bg group-hover:scale-110 transition-transform duration-300" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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&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)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { useDiceContext } from "./DiceContext";
|
||||||
|
import type { RollOutcome } from "./DiceRoller";
|
||||||
|
|
||||||
|
export interface RollEntry {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
timestamp: number;
|
||||||
|
dice: Record<number, number>; // requested pool, e.g. { 20: 1 }
|
||||||
|
raw: { sides: number; value: number }[];
|
||||||
|
sum: number; // sum of raw dice values
|
||||||
|
modifier: number;
|
||||||
|
total: number; // sum + modifier
|
||||||
|
advantage?: "none" | "adv" | "dis";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RollHistoryContextValue {
|
||||||
|
history: RollEntry[];
|
||||||
|
toasts: RollEntry[];
|
||||||
|
dismissToast: (id: number) => void;
|
||||||
|
roll: (opts: {
|
||||||
|
pool: Record<number, number>;
|
||||||
|
label: string;
|
||||||
|
modifier?: number;
|
||||||
|
advantage?: "none" | "adv" | "dis";
|
||||||
|
}) => Promise<RollEntry | null>;
|
||||||
|
clear: () => void;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<RollHistoryContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useRollHistory() {
|
||||||
|
const ctx = useContext(Ctx);
|
||||||
|
if (!ctx) throw new Error("useRollHistory must be used within RollHistoryProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
export function RollHistoryProvider({ children }: { children: ReactNode }) {
|
||||||
|
const dice = useDiceContext();
|
||||||
|
const [history, setHistory] = useState<RollEntry[]>([]);
|
||||||
|
const [toasts, setToasts] = useState<RollEntry[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const dismissToast = useCallback((id: number) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const roll = useCallback<RollHistoryContextValue["roll"]>(
|
||||||
|
async ({ pool, label, modifier = 0, advantage = "none" }) => {
|
||||||
|
const roller = dice.rollerRef.current;
|
||||||
|
if (!roller) return null;
|
||||||
|
|
||||||
|
// For advantage/disadvantage on a single d20, roll twice and pick.
|
||||||
|
const effectivePool = { ...pool };
|
||||||
|
const isD20Only =
|
||||||
|
Object.keys(pool).length === 1 &&
|
||||||
|
pool[20] === 1 &&
|
||||||
|
advantage !== "none";
|
||||||
|
if (isD20Only) effectivePool[20] = 2;
|
||||||
|
|
||||||
|
const outcome: RollOutcome = await roller.roll(effectivePool, label);
|
||||||
|
|
||||||
|
let raw = outcome.roll.map((r) => ({ sides: r.sides, value: r.value }));
|
||||||
|
|
||||||
|
// Apply advantage/disadvantage: pick higher/lower d20, keep others.
|
||||||
|
if (isD20Only) {
|
||||||
|
const d20s = raw.filter((r) => r.sides === 20).map((r) => r.value);
|
||||||
|
const pickedValue = advantage === "adv" ? Math.max(...d20s) : Math.min(...d20s);
|
||||||
|
raw = [{ sides: 20, value: pickedValue }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sum = raw.reduce((a, r) => a + r.value, 0);
|
||||||
|
const entry: RollEntry = {
|
||||||
|
id: nextId++,
|
||||||
|
label,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
dice: pool,
|
||||||
|
raw,
|
||||||
|
sum,
|
||||||
|
modifier,
|
||||||
|
total: sum + modifier,
|
||||||
|
advantage,
|
||||||
|
};
|
||||||
|
setHistory((prev) => [entry, ...prev].slice(0, 100));
|
||||||
|
setToasts((prev) => [entry, ...prev].slice(0, 4));
|
||||||
|
return entry;
|
||||||
|
},
|
||||||
|
[dice.rollerRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clear = useCallback(() => setHistory([]), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Ctx.Provider value={{ history, toasts, dismissToast, roll, clear, open, setOpen }}>
|
||||||
|
{children}
|
||||||
|
</Ctx.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRollHistory } from "./RollHistoryContext";
|
||||||
|
|
||||||
|
function titleCase(s: string): string {
|
||||||
|
return s.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(ts: number) {
|
||||||
|
const s = Math.floor((Date.now() - ts) / 1000);
|
||||||
|
if (s < 5) return "just now";
|
||||||
|
if (s < 60) return `${s}s ago`;
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||||
|
return `${Math.floor(s / 3600)}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RollHistorySidebar() {
|
||||||
|
const { history, open, setOpen } = useRollHistory();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Toggle handle — always visible on right edge */}
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="fixed right-0 top-1/2 -translate-y-1/2 z-[10001] px-2 py-4 bg-surface border border-r-0 border-gold/40 rounded-l-lg text-gold hover:bg-gold/10 transition"
|
||||||
|
aria-label="Toggle roll history"
|
||||||
|
>
|
||||||
|
<span className="text-xs writing-mode-vertical" style={{ writingMode: "vertical-rl" }}>
|
||||||
|
Rolls {history.length > 0 && `(${history.length})`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<aside
|
||||||
|
className={`fixed right-0 top-0 h-screen w-80 z-[10002] bg-surface border-l border-gold/30 shadow-2xl transform transition-transform ${
|
||||||
|
open ? "translate-x-0" : "translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gold/20">
|
||||||
|
<h2 className="text-sm font-bold text-gold uppercase tracking-wide">Roll History</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="text-neutral-400 hover:text-neutral-100 text-2xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto h-[calc(100vh-3.5rem)] p-3 space-y-2">
|
||||||
|
{history.length === 0 && (
|
||||||
|
<p className="text-xs text-neutral-500 italic text-center py-8">
|
||||||
|
No rolls yet. Click any ability, save, or skill on the sheet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{history.map((r) => (
|
||||||
|
<div key={r.id} className="p-3 border border-neutral-700 rounded bg-neutral-950/50">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="text-sm font-medium text-ink truncate">{titleCase(r.label)}</div>
|
||||||
|
<div className="text-2xl font-bold text-gold leading-none">{r.total}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-neutral-400">
|
||||||
|
{r.raw.map((d, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
<span className={d.value === d.sides ? "text-emerald-400" : d.value === 1 ? "text-red-400" : ""}>
|
||||||
|
{d.value}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-600"> (d{d.sides})</span>
|
||||||
|
{i < r.raw.length - 1 && <span className="text-neutral-600"> + </span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{r.modifier !== 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-600"> {r.modifier >= 0 ? "+" : ""}</span>
|
||||||
|
<span>{r.modifier}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{r.advantage && r.advantage !== "none" && (
|
||||||
|
<span className="ml-2 text-[10px] uppercase text-amber-400">{r.advantage}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[10px] text-neutral-500">{timeAgo(r.timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRollHistory, type RollEntry } from "./RollHistoryContext";
|
||||||
|
|
||||||
|
function titleCase(s: string): string {
|
||||||
|
return s.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RollToasts() {
|
||||||
|
const { toasts, dismissToast } = useRollHistory();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-16 right-4 z-[10001] flex flex-col gap-2 pointer-events-none w-72">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<ToastCard key={t.id} entry={t} onDismiss={() => dismissToast(t.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastCard({ entry, onDismiss }: { entry: RollEntry; onDismiss: () => void }) {
|
||||||
|
const [phase, setPhase] = useState<"in" | "out">("in");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fadeTimer = setTimeout(() => setPhase("out"), 3500);
|
||||||
|
const removeTimer = setTimeout(onDismiss, 4200);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(fadeTimer);
|
||||||
|
clearTimeout(removeTimer);
|
||||||
|
};
|
||||||
|
}, [onDismiss]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`p-3 bg-surface border border-gold/30 rounded-xl shadow-xl pointer-events-auto ${
|
||||||
|
phase === "in"
|
||||||
|
? "animate-[cardIn_0.25s_ease-out_forwards]"
|
||||||
|
: "animate-[cardOut_0.7s_ease-in_forwards]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="text-sm font-medium text-ink truncate">{titleCase(entry.label)}</div>
|
||||||
|
<div className="text-2xl font-bold text-gold leading-none">{entry.total}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-neutral-400 truncate">
|
||||||
|
{entry.raw.map((d, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
d.value === d.sides
|
||||||
|
? "text-emerald-400"
|
||||||
|
: d.value === 1 && d.sides === 20
|
||||||
|
? "text-red-400"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{d.value}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-600">(d{d.sides})</span>
|
||||||
|
{i < entry.raw.length - 1 && <span className="text-neutral-600"> + </span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{entry.modifier !== 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-600"> {entry.modifier >= 0 ? "+" : ""}</span>
|
||||||
|
<span>{entry.modifier}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{entry.advantage && entry.advantage !== "none" && (
|
||||||
|
<span className="ml-2 text-[10px] uppercase text-amber-400">{entry.advantage}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
|
export function useIsAdmin(): boolean {
|
||||||
|
const { data } = useSession();
|
||||||
|
return data?.user?.isAdmin === true;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
+281
-38
@@ -1,4 +1,4 @@
|
|||||||
import { Dice, ExpandedDice } from "./core";
|
import { Dice, Effect, ExpandedDice } from "./core";
|
||||||
|
|
||||||
export type Ability =
|
export type Ability =
|
||||||
| "strength"
|
| "strength"
|
||||||
@@ -8,11 +8,231 @@ export type Ability =
|
|||||||
| "wisdom"
|
| "wisdom"
|
||||||
| "charisma";
|
| "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 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 = {
|
export type ContentEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
type:
|
type:
|
||||||
| "species"
|
| "species"
|
||||||
| "class"
|
| "class"
|
||||||
@@ -23,59 +243,44 @@ export type ContentEntry = {
|
|||||||
| "armor"
|
| "armor"
|
||||||
| "feature"
|
| "feature"
|
||||||
| "background";
|
| "background";
|
||||||
|
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
data: unknown;
|
data: unknown;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type ContentPack = {
|
export type ContentPack = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
|
visibility: Visibility;
|
||||||
visibility: "private" | "public";
|
|
||||||
|
|
||||||
entries: ContentEntry[];
|
entries: ContentEntry[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export type Armor = {
|
export type Armor = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
armorClass: number;
|
armorClass: number;
|
||||||
|
dexterityBonus: "full" | "limited" | "none";
|
||||||
dexterityBonus:
|
|
||||||
| "full"
|
|
||||||
| "limited"
|
|
||||||
| "none";
|
|
||||||
|
|
||||||
maxDexterity?: number;
|
maxDexterity?: number;
|
||||||
shieldBonus?: number;
|
shieldBonus?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type Weapon = {
|
export type Weapon = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
damage: {
|
damage: {
|
||||||
dice: Dice;
|
dice: Dice;
|
||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
properties: string[];
|
|
||||||
|
|
||||||
ability: {
|
ability: {
|
||||||
default: Ability;
|
default: Ability;
|
||||||
options: {
|
options: {
|
||||||
name: string;
|
name: string;
|
||||||
ability: Ability;
|
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;
|
proficientRequired: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type AbilityScores = {
|
export type AbilityScores = {
|
||||||
strength: number;
|
strength: number;
|
||||||
@@ -84,56 +289,94 @@ export type AbilityScores = {
|
|||||||
intelligence: number;
|
intelligence: number;
|
||||||
wisdom: number;
|
wisdom: number;
|
||||||
charisma: number;
|
charisma: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy alias — prefer ClassDef.
|
||||||
|
*/
|
||||||
export type Class = {
|
export type Class = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
hitDice: ExpandedDice;
|
hitDice: ExpandedDice;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type CharacterClass = {
|
export type CharacterClass = {
|
||||||
classId: string;
|
classId: string;
|
||||||
level: number;
|
level: number;
|
||||||
}
|
subclassId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CharacterHP = {
|
export type CharacterHP = {
|
||||||
method: RollMethod;
|
method: RollMethod;
|
||||||
max: number;
|
max: number;
|
||||||
|
current?: number;
|
||||||
|
temporary?: number;
|
||||||
history: {
|
history: {
|
||||||
level: number;
|
level: number;
|
||||||
classId: string;
|
classId: string;
|
||||||
|
|
||||||
roll?: number;
|
roll?: number;
|
||||||
increase: 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 = {
|
export type Character = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
name: string;
|
name: string;
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
alignment?: Alignment;
|
||||||
|
|
||||||
speciesId: string;
|
speciesId: string;
|
||||||
|
backgroundId?: string;
|
||||||
classes: CharacterClass[];
|
classes: CharacterClass[];
|
||||||
|
|
||||||
hp: CharacterHP;
|
hp: CharacterHP;
|
||||||
|
|
||||||
abilities: AbilityScores;
|
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[];
|
inventory: string[];
|
||||||
|
|
||||||
equipped: {
|
equipped: {
|
||||||
armor?: string;
|
armor?: string;
|
||||||
weapons: string[];
|
weapons: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
magic: {
|
magic: {
|
||||||
spells: Record<string, string[]>; // { <level>: <spell_id>}
|
spells: Record<string, string[]>;
|
||||||
slots: Record<string, [number, number]> // { <level>: [<used>, <max>] }
|
slots: Record<string, [number, number]>;
|
||||||
}
|
};
|
||||||
|
|
||||||
contentSources: string[];
|
contentSources: string[];
|
||||||
}
|
};
|
||||||
|
|||||||
+80
-10
@@ -1,26 +1,96 @@
|
|||||||
import { Ability } from "./content";
|
import { Ability, Skill } from "./content";
|
||||||
|
|
||||||
export type Dice = Record<string, number>;
|
export type Dice = Record<string, number>;
|
||||||
|
|
||||||
export type ExpandedDice = {
|
export type ExpandedDice = {
|
||||||
sides: number;
|
sides: number;
|
||||||
count: 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 = {
|
export type Effect = {
|
||||||
target: string;
|
target: EffectTarget;
|
||||||
operation: "increase" | "decrease" | "roll";
|
operation: "grant" | "increase" | "decrease" | "set";
|
||||||
value: number;
|
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 = {
|
export type AttackResult = {
|
||||||
ability: Ability;
|
ability: Ability;
|
||||||
|
|
||||||
attackBonus: number;
|
attackBonus: number;
|
||||||
|
|
||||||
damage: {
|
damage: {
|
||||||
dice: Dice;
|
dice: Dice;
|
||||||
modifier: number;
|
modifier: number;
|
||||||
type: string;
|
type: string;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
Vendored
+17
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user