diff --git a/.gitignore b/.gitignore index 5ef6a52..6436d77 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.aider* diff --git a/next.config.ts b/next.config.ts index e9ffa30..bd7c7c9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ + devIndicators: false }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index ec74021..794a17a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,18 +8,25 @@ "name": "dndextended", "version": "0.1.0", "dependencies": { + "@hookform/resolvers": "^5.5.7", "@iconify-react/fa6-solid": "^1.0.2", "@iconify-react/game-icons": "^1.0.3", "@iconify-react/mdi": "^1.0.2", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.1", + "bcryptjs": "^3.0.3", + "mongodb": "^7.5.0", "next": "16.2.10", + "next-auth": "^5.0.0-beta.31", "react": "19.2.4", "react-dom": "19.2.4", - "three": "^0.185.1" + "react-hook-form": "^7.83.0", + "three": "^0.185.1", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -42,6 +49,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@auth/core": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz", + "integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -437,6 +473,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", @@ -474,6 +534,116 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.5.7.tgz", + "integrity": "sha512-CyPCYV8/KlXfEXLWj8HHHhVsR/IZ6Ckm3b/a4fWtO/lRnRK1huqncb8LlAWrmpPRsse5glF5aVuDRMHEr3UGag==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "@sinclair/typebox": ">=0.25.24", + "@standard-schema/spec": "^1.0.0", + "@typeschema/main": ">=0.13.7", + "@vinejs/vine": "^2.0.0 || ^3.0.0", + "ajv": "^8.12.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "arktype": "^2.0.0", + "ata-validator": "^0.7.0", + "class-transformer": ">=0.4.0", + "class-validator": ">=0.12.0", + "computed-types": "^1.0.0", + "effect": "^3.10.3", + "fluentvalidation-ts": "^3.0.0", + "fp-ts": "^2.7.0", + "io-ts": "^2.0.0", + "joi": "^17.0.0", + "nope-validator": ">=0.12.0", + "react-hook-form": "^7.55.0", + "superstruct": ">=0.12.0", + "typanion": "^3.3.2", + "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", + "vest": ">=3.0.0", + "yup": "^1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + }, + "@standard-schema/spec": { + "optional": true + }, + "@typeschema/main": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "ajv": { + "optional": true + }, + "ajv-errors": { + "optional": true + }, + "ajv-formats": { + "optional": true + }, + "arktype": { + "optional": true + }, + "ata-validator": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "computed-types": { + "optional": true + }, + "effect": { + "optional": true + }, + "fluentvalidation-ts": { + "optional": true + }, + "fp-ts": { + "optional": true + }, + "io-ts": { + "optional": true + }, + "joi": { + "optional": true + }, + "nope-validator": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "typanion": { + "optional": true + }, + "valibot": { + "optional": true + }, + "vest": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1158,6 +1328,15 @@ "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", "license": "Apache-2.0" }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.12.tgz", + "integrity": "sha512-QAfAMwNgnYxZ2C6D1HgeP7Gc4i/uvJRim415PCIL9ptRxWMNbWeLBYb2/9R4pGKny/s1FVu2JA2cxCUBUOggrA==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@monogrid/gainmap-js": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", @@ -1393,6 +1572,15 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@react-three/drei": { "version": "10.7.7", "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", @@ -1488,6 +1676,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1797,6 +1991,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/draco3d": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", @@ -1888,12 +2089,27 @@ "meshoptimizer": "~1.1.1" } }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", "license": "MIT" }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.63.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", @@ -2584,23 +2800,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2886,6 +3085,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -2953,6 +3161,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bson": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.1.tgz", + "integrity": "sha512-h/C0qe6857pQhcSJHLfsR1uYGj98Ge3wKAD3Ed9KqH3wcVh+BM4Jq4xISD7vs9OPuT07n+q3QQVjslJ286j6ag==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -3932,6 +4149,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -4000,7 +4241,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5031,6 +5272,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5081,13 +5331,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -5526,6 +5769,12 @@ "node": ">= 0.4" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5588,6 +5837,65 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mongodb": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.5.0.tgz", + "integrity": "sha512-5FnrEDLnvp6ycUOGLNLLU33BfCx2qmp2mJjGPDwKLruYsVzXVSK5fsGpoDXvsXJwBfBsD7ebMRdawbDxC2814g==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.4.11", + "bson": "^7.2.0", + "mongodb-connection-string-url": "^7.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": "^7.2.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5689,6 +5997,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.31", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.31.tgz", + "integrity": "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.2" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -5746,6 +6081,15 @@ "node": ">=18" } }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6040,6 +6384,25 @@ "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", "license": "ISC" }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6076,7 +6439,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6124,6 +6486,22 @@ "react": "^19.2.4" } }, + "node_modules/react-hook-form": { + "version": "7.83.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.83.0.tgz", + "integrity": "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6562,6 +6940,15 @@ "node": ">=0.10.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -6924,6 +7311,18 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/troika-three-text": { "version": "0.52.4", "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", @@ -7299,6 +7698,28 @@ "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", "license": "MIT" }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7437,7 +7858,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 05dc066..3e27743 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,25 @@ "lint": "eslint" }, "dependencies": { + "@hookform/resolvers": "^5.5.7", "@iconify-react/fa6-solid": "^1.0.2", "@iconify-react/game-icons": "^1.0.3", "@iconify-react/mdi": "^1.0.2", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.1", + "bcryptjs": "^3.0.3", + "mongodb": "^7.5.0", "next": "16.2.10", + "next-auth": "^5.0.0-beta.31", "react": "19.2.4", "react-dom": "19.2.4", - "three": "^0.185.1" + "react-hook-form": "^7.83.0", + "three": "^0.185.1", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/request.json b/request.json new file mode 100644 index 0000000..4b76435 --- /dev/null +++ b/request.json @@ -0,0 +1,16 @@ +{ + "model": "qwen2.5-coder:7b", + "messages": [{"role": "user", "content": "What is 2+2? Use the calculator tool."}], + "tools": [{ + "type": "function", + "function": { + "name": "calculator", + "description": "Adds two numbers", + "parameters": { + "type": "object", + "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, + "required": ["a", "b"] + } + } + }] +} \ No newline at end of file diff --git a/src/app/ManualDiceLayout.tsx b/src/app/ManualDiceLayout.tsx new file mode 100644 index 0000000..f71bd88 --- /dev/null +++ b/src/app/ManualDiceLayout.tsx @@ -0,0 +1,51 @@ +import { useDiceContext } from "@/components/DiceContext"; +import { RollOutcome } from "@/components/DiceRoller"; +import { Button } from "@/components/DiceSelector"; +import RollCard from "@/components/RollCard"; +import React, { useCallback, useEffect, useState } from "react"; + +interface DisplayRoll extends RollOutcome { + id: number; +} + +let nextId = 0; + +export default function ManualDiceLayout({ + children +}: Readonly<{ + children: React.ReactNode +}>) { + const { rollerRef, onRollComplete } = useDiceContext(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [history, setHistory] = useState([]); + const [displayed, setDisplayed] = useState([]); + + useEffect(() => { + return onRollComplete((outcome) => { + setHistory((prev) => [...prev, outcome]); + setDisplayed((prev) => [...prev, { ...outcome, id: nextId++ }]); + }); + }, [onRollComplete]); + + const removeDisplay = useCallback((id: number) => { + setDisplayed((prev) => prev.filter((r) => r.id !== id)); + }, []); + + return ( +
+
+
+
+ {displayed.map((item) => ( + removeDisplay(item.id)} + /> + ))} +
+
+ ) +} \ No newline at end of file diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..c55a45e --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/lib/auth"; + +export const { GET, POST } = handlers; diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..c4b6d3e --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -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 }); +} diff --git a/src/app/api/backgrounds/route.ts b/src/app/api/backgrounds/route.ts new file mode 100644 index 0000000..3a55d27 --- /dev/null +++ b/src/app/api/backgrounds/route.ts @@ -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); diff --git a/src/app/api/character/route.ts b/src/app/api/character/route.ts new file mode 100644 index 0000000..2259de9 --- /dev/null +++ b/src/app/api/character/route.ts @@ -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 }); +} diff --git a/src/app/api/classes/route.ts b/src/app/api/classes/route.ts new file mode 100644 index 0000000..c6c8252 --- /dev/null +++ b/src/app/api/classes/route.ts @@ -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); diff --git a/src/app/api/features/[id]/route.ts b/src/app/api/features/[id]/route.ts new file mode 100644 index 0000000..d7e407f --- /dev/null +++ b/src/app/api/features/[id]/route.ts @@ -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 = {}; + 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 }); +} diff --git a/src/app/api/features/route.ts b/src/app/api/features/route.ts new file mode 100644 index 0000000..f51475b --- /dev/null +++ b/src/app/api/features/route.ts @@ -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); diff --git a/src/app/api/species/route.ts b/src/app/api/species/route.ts new file mode 100644 index 0000000..0a80762 --- /dev/null +++ b/src/app/api/species/route.ts @@ -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); diff --git a/src/app/api/spells/route.ts b/src/app/api/spells/route.ts new file mode 100644 index 0000000..ae2fa4a --- /dev/null +++ b/src/app/api/spells/route.ts @@ -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); diff --git a/src/app/api/subclasses/route.ts b/src/app/api/subclasses/route.ts new file mode 100644 index 0000000..d440689 --- /dev/null +++ b/src/app/api/subclasses/route.ts @@ -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); diff --git a/src/app/charactercreator/CreatorContext.tsx b/src/app/charactercreator/CreatorContext.tsx new file mode 100644 index 0000000..104a319 --- /dev/null +++ b/src/app/charactercreator/CreatorContext.tsx @@ -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; +} + +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) => void; + step: number; + setStep: (n: number) => void; + + activeRulesets: Ruleset[]; + toggleRuleset: (r: Ruleset) => void; + + species: Species[]; + classes: ClassDef[]; + backgrounds: Background[]; + features: Record; + spells: Spell[]; + subclasses: Subclass[]; + + allSpecies: Species[]; + allClasses: ClassDef[]; + allBackgrounds: Background[]; + + refreshSpecies: () => Promise; + refreshClasses: () => Promise; + refreshBackgrounds: () => Promise; + refreshFeatures: () => Promise; + refreshSpells: () => Promise; + refreshSubclasses: () => Promise; + loading: boolean; +} + +const Ctx = createContext(null); + +export function useCreator() { + const ctx = useContext(Ctx); + if (!ctx) throw new Error("useCreator must be inside CreatorProvider"); + return ctx; +} + +async function fetchList(path: string): Promise { + const r = await fetch(path); + if (!r.ok) return []; + const { results } = await r.json(); + return results ?? []; +} + +function inRulesets(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(DEFAULT_DRAFT); + const [step, setStep] = useState(0); + const [activeRulesets, setActiveRulesets] = useState(["5e", "homebrew"]); + const [speciesAll, setSpeciesAll] = useState([]); + const [classesAll, setClassesAll] = useState([]); + const [backgroundsAll, setBackgroundsAll] = useState([]); + const [featureList, setFeatureList] = useState([]); + const [spellsAll, setSpellsAll] = useState([]); + const [subclassesAll, setSubclassesAll] = useState([]); + const [loading, setLoading] = useState(false); + + const update = useCallback((partial: Partial) => { + 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 = {}; + 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 {children}; +} diff --git a/src/app/charactercreator/Wizard.tsx b/src/app/charactercreator/Wizard.tsx new file mode 100644 index 0000000..2e41580 --- /dev/null +++ b/src/app/charactercreator/Wizard.tsx @@ -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 ( +
+ + +
+ + +
+ + {step < STEPS.length - 1 && ( + + )} +
+
+
+ ); +} + +export default function Wizard() { + return ( + + + + ); +} diff --git a/src/app/charactercreator/components/AttachedFeatures.tsx b/src/app/charactercreator/components/AttachedFeatures.tsx new file mode 100644 index 0000000..326967f --- /dev/null +++ b/src/app/charactercreator/components/AttachedFeatures.tsx @@ -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 ( +
+ {attached.length === 0 && ( +

No features attached.

+ )} + {attached.map((f) => ( +
+
+
+ {f.name} + {f.level != null && Lv {f.level}} + {f.effects?.length > 0 && ( + {f.effects.length} effect(s) + )} +
+ {f.description &&
{f.description}
} +
+ +
+ ))} + +
+ + +
+ + setPickerOpen(false)} title="Attach existing feature"> + 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" + /> +
+ {filtered.length === 0 &&

No matches.

} + {filtered.map((f) => ( + + ))} +
+
+ + setBuilderOpen(false)} title="Create feature" wide confirmClose> + setBuilderOpen(false)} + onSave={(f) => { + onFeatureCreated(f); + add(f.id); + setBuilderOpen(false); + }} + /> + +
+ ); +} diff --git a/src/app/charactercreator/components/BackgroundBuilder.tsx b/src/app/charactercreator/components/BackgroundBuilder.tsx new file mode 100644 index 0000000..35760b7 --- /dev/null +++ b/src/app/charactercreator/components/BackgroundBuilder.tsx @@ -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([]); + const [toolsStr, setToolsStr] = useState(""); + const [languagesStr, setLanguagesStr] = useState(""); + const [featureIds, setFeatureIds] = useState([]); + const [visibility, setVisibility] = useState<"private" | "public">("private"); + const [ruleset, setRuleset] = useState("5e"); + const [busy, setBusy] = useState(false); + + const available = Object.values(features); + const toggle = (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 ( +
+
+ + setName(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" /> +
+
+ +