commit cc5be652b8907512b85d308a6e5a2106f3fcf741 Author: Pierre Date: Sun Aug 23 09:06:58 2026 +0000 Initial commit: Wycz WoW Portfolio diff --git a/.env b/.env new file mode 100644 index 0000000..30aa385 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +ADMIN_PASSWORD=268bc591ce7e +SESSION_SECRET=72df71ef40d021cc1da8ddaf7ca7d5408552b68f058cc62fc9d49588478f3e8a diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3cc70cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +/data/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..90bb277 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Wycz - Portfolio World of Warcraft + +Site portfolio server-rendered dédié à l'univers World of Warcraft, avec zone d'administration, récupération de données externes planifiée et proxy model-viewer. + +## Stack technique + +| Composant | Technologie | +|-----------|------------| +| Runtime | Node.js 22 | +| Framework | Express (ESM) | +| Base de données | SQLite (better-sqlite3) | +| Frontend | HTML/CSS/JS statique | + +## Déploiement + +```bash +# Container Docker +docker compose up -d wycz +``` + +Accessible sur le port **3004**. + +## Structure + +``` +/opt/web/wycz/backend/ +├── server.js # Point d'entrée Express +├── scheduler.js # Planificateur de tâches (cron) +├── services/fetcher.js # Récupération de données externes +├── public/ # Assets statiques +└── data/ # SQLite (volume Docker) +``` + +## Variables d'environnement + +| Variable | Description | Valeur | +|----------|-------------|--------| +| `PORT` | Port du serveur | `3004` | +| `ADMIN_PASSWORD` | Mot de passe admin | `Pierre2026!` | +| `SESSION_SECRET` | Secret de session | `wycz-session-secret-2026` | +| `DATA_DIR` | Répertoire de données | `/data` | + +## Domaine de production + +- **URL** : https://wycz.fr +- **LXC** : 107 (IP 10.10.10.24) +- **SSL** : Let's Encrypt via Nginx Proxy Manager diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..14b1cec --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +data +.git +*.log diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..9797727 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,4 @@ +PORT=3004 +ADMIN_PASSWORD=change-me-now +SESSION_SECRET=change-me-session-secret +DATA_DIR=./data diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b876010 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-slim +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY . . +EXPOSE 3004 +CMD ["node", "src/server.js"] diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..7c14ad8 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1252 @@ +{ + "name": "wycz-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wycz-backend", + "version": "1.0.0", + "dependencies": { + "better-sqlite3": "^11.10.0", + "express": "^4.21.2" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..6c4f972 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,13 @@ +{ + "name": "wycz-backend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/server.js" + }, + "dependencies": { + "better-sqlite3": "^11.10.0", + "express": "^4.21.2" + } +} diff --git a/backend/public/admin.html b/backend/public/admin.html new file mode 100644 index 0000000..798ac2b --- /dev/null +++ b/backend/public/admin.html @@ -0,0 +1,195 @@ + + + + + + + Wycz — Administration + + + + + + + +
+ + + +
+ + + + + diff --git a/backend/public/css/style.css b/backend/public/css/style.css new file mode 100644 index 0000000..56d6e37 --- /dev/null +++ b/backend/public/css/style.css @@ -0,0 +1,1267 @@ +:root { + --bg: #0b0e13; + --bg-2: #12161f; + --bg-3: #1a2130; + --border: #232c3d; + --text: #e6e9f0; + --muted: #8b94a7; + --gold: #f0b429; + --green: #2ecc71; + --red: #e74c3c; + --blue: #3b82f6; + --orange: #f59e0b; + --radius: 12px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +#starfield { + position: fixed; + inset: 0; + overflow: hidden; + pointer-events: none; + z-index: 0; +} +.star { + position: absolute; + top: -12%; + display: block; + border-radius: 9999px; + will-change: top, transform, opacity; + animation-name: star-fall, star-sway; + animation-timing-function: linear, ease-in-out; + animation-iteration-count: infinite, infinite; + animation-direction: normal, alternate; +} +@keyframes star-fall { + from { top: -12%; } + to { top: 112%; } +} +@keyframes star-sway { + 0% { transform: translateX(-5px); opacity: calc(var(--peak, 0.3) * 0.35); } + 50% { transform: translateX(3px); opacity: var(--peak, 0.3); } + 100% { transform: translateX(5px); opacity: calc(var(--peak, 0.3) * 0.5); } +} +@media (prefers-reduced-motion: reduce) { + .star { animation: none; opacity: 0; } +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: + radial-gradient(1200px 600px at 80% -10%, rgba(59, 130, 246, 0.08), transparent 60%), + radial-gradient(900px 500px at -10% 10%, rgba(240, 180, 41, 0.05), transparent 60%), + var(--bg); + color: var(--text); + min-height: 100vh; +} + +.site-header { + border-bottom: 1px solid var(--border); + background: rgba(18, 22, 31, 0.85); + backdrop-filter: blur(8px); + position: sticky; + top: 0; + z-index: 10; +} +.header-inner { + max-width: 1200px; + margin: 0 auto; + padding: 14px 20px; + display: flex; + align-items: center; + justify-content: space-between; +} +.brand { display: flex; align-items: center; gap: 12px; } +.brand-logo { + width: 42px; height: 42px; + display: grid; place-items: center; + background: linear-gradient(135deg, var(--gold), #d35400); + color: #10131a; font-weight: 700; font-size: 22px; + border-radius: 10px; + font-family: 'Rajdhani', sans-serif; + object-fit: cover; +} +img.brand-logo { + width: auto; height: 64px; + border-radius: 12px; + background: none; + padding: 0; + object-fit: contain; +} +.header-classes { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + justify-content: center; + max-width: 40%; +} +.header-class-icon { + position: relative; + flex: 0 0 auto; + aspect-ratio: auto; + padding: 3px 2px 1px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease; +} +.header-class-icon > img { width: 24px; height: 24px; object-fit: contain; display: block; pointer-events: none; } +.header-class-icon.dim { opacity: 0.45; filter: grayscale(0.8); } +.header-class-icon:hover { + opacity: 1; + filter: none; + transform: translateY(-2px); + border-color: var(--class-color, var(--border)); + background: var(--bg-3); +} +.hdr-titles { display: flex; align-items: center; gap: 3px; margin-top: 1px; } +.hdr-vignette { width: 14px; height: 14px; border-radius: 3px; object-fit: cover; border: 1px solid var(--border); flex-shrink: 0; } +.brand-text h1 { font-family: 'Rajdhani', sans-serif; font-size: 22px; letter-spacing: 1px; } +.brand-text p { font-size: 12px; color: var(--muted); margin-top: -2px; } +.header-status { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--muted); } +.header-right { display: flex; align-items: center; gap: 12px; } +.socials { display: flex; align-items: center; gap: 8px; } +.social-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--bg-3); + border: 1px solid var(--border); + color: var(--muted); + transition: color 0.15s ease, border-color 0.15s ease, transform 0.15s ease; +} +.social-btn svg { width: 18px; height: 18px; fill: currentColor; } +.social-btn:hover { color: var(--text); border-color: var(--gold); transform: translateY(-2px); } + +.container { max-width: 1200px; margin: 0 auto; padding: 28px 20px 60px; position: relative; z-index: 1; } + +.section-title { + font-family: 'Rajdhani', sans-serif; + font-size: 24px; + letter-spacing: 1px; + margin: 32px 0 16px; + border-left: 4px solid var(--gold); + padding-left: 12px; +} + +.goals-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 14px; +} +.overview-grid { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 14px; +} +.overview-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + text-align: center; + transition: transform 0.15s ease, border-color 0.15s ease; + flex: 0 1 250px; + min-width: 210px; +} +.overview-card.bests-card { flex: 1 1 100%; text-align: left; } +.bests-grid { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; } +.best-panel { + flex: 1 1 0; + min-width: 145px; + max-width: 220px; + background: var(--bg-3); + border: 1px solid var(--border); + border-top: 3px solid var(--brk, var(--border)); + border-radius: 12px; + padding: 10px 8px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-align: center; + transition: transform 0.15s ease, border-color 0.15s ease; +} +.best-panel:hover { transform: translateY(-2px); border-color: var(--brk, #33415a); } +.best-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: center; } +.best-brk { font-family: 'Rajdhani', sans-serif; font-size: 18px; font-weight: 700; color: var(--brk, var(--text)); } +.best-char { display: flex; flex-direction: column; align-items: center; gap: 3px; } +.best-avatar { + width: 46px; + height: 46px; + border-radius: 10px; + object-fit: cover; + border: 1px solid var(--border); + background: var(--bg-2); +} +.best-avatar-empty { display: block; background: var(--bg-2); } +.best-cname { font-size: 13px; font-weight: 600; line-height: 1.2; } +.best-csub { font-size: 11.5px; color: var(--muted); } +.best-rating { font-family: 'Rajdhani', sans-serif; font-size: 24px; font-weight: 700; color: var(--gold); line-height: 1; } +.best-empty { color: var(--muted); padding: 22px 0; font-size: 16px; } +.top-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.top-item { + display: flex; + align-items: center; + gap: 12px; + background: var(--bg-3); + border: 1px solid var(--border); + border-left: 4px solid var(--muted); + border-radius: 10px; + padding: 10px 14px; + transition: transform 0.15s ease, border-color 0.15s ease; +} +.top-item:hover { transform: translateX(2px); border-color: #33415a; } +.top-item.legendary { border-left-color: #c9a24b; } +.top-item.epic { border-left-color: #a34c8f; } +.top-avatar { + width: 40px; + height: 40px; + border-radius: 9px; + object-fit: cover; + border: 1px solid var(--border); + background: var(--bg-2); + flex-shrink: 0; +} +.top-rank { + font-family: 'Rajdhani', sans-serif; + font-size: 20px; + font-weight: 700; + color: var(--muted); + width: 22px; + text-align: center; + flex-shrink: 0; +} +.top-main { flex: 1; min-width: 0; } +.top-name { font-family: 'Rajdhani', sans-serif; font-size: 17px; font-weight: 700; line-height: 1.1; } +.top-sub { font-size: 12.5px; color: var(--muted); } +.top-rating { font-family: 'Rajdhani', sans-serif; font-size: 20px; font-weight: 700; color: var(--gold); white-space: nowrap; flex-shrink: 0; } +.overview-card:hover { transform: translateY(-2px); border-color: #33415a; } +.overview-card.achieved { + border-color: rgba(46, 204, 113, 0.55); + background: linear-gradient(180deg, rgba(46, 204, 113, 0.10), rgba(46, 204, 113, 0.03) 60%), var(--bg-2); + box-shadow: 0 0 22px rgba(46, 204, 113, 0.12); +} +.overview-card.achieved .overview-value b, +.overview-card.achieved .overview-value { color: var(--green); } +.overview-card.achieved .progress-bar .fill { background: linear-gradient(90deg, var(--green), #58e08a); } +.overview-card.achieved:hover { border-color: var(--green); } +.overview-head { display: flex; justify-content: center; align-items: center; gap: 8px; flex-direction: column; } +.overview-head h3 { font-family: 'Rajdhani', sans-serif; font-size: 16px; font-weight: 700; letter-spacing: 0.5px; } +.overview-value { font-family: 'Rajdhani', sans-serif; font-size: 24px; font-weight: 700; color: var(--gold); } +.overview-value b { color: var(--gold); } +.overview-sub { font-size: 12.5px; color: var(--muted); line-height: 1.4; } +.overview-badges { display: flex; justify-content: center; flex-wrap: wrap; gap: 6px; } +.overview-badge { + width: 26px; height: 26px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-3); + object-fit: contain; + padding: 2px; +} +.overview-badge-txt { + display: grid; place-items: center; + font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 13px; + color: var(--muted); +} +.twitch-card { grid-column: span 2; } +.twitch-link { align-self: flex-start; text-decoration: none; display: inline-flex; } +.live-status { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--muted); + text-decoration: none; + font-size: 13px; + font-weight: 600; +} +.live-status:hover { color: var(--text); } +.twitch-logo-img { display: inline-flex; width: 20px; height: 20px; color: #9146FF; filter: drop-shadow(0 0 3px rgba(145, 70, 255, 0.55)); } +.twitch-logo-img svg { width: 100%; height: 100%; fill: currentColor; display: block; } +.live-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--muted); + box-shadow: 0 0 0 0 rgba(139, 148, 167, 0.5); +} +.live-dot.on { + background: var(--red); + box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7); + animation: pulse 1.6s infinite; +} +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7); } + 70% { box-shadow: 0 0 0 9px rgba(231, 76, 60, 0); } + 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0); } +} +.goal-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + position: relative; + transition: transform 0.15s ease, border-color 0.15s ease; +} +.goal-card:hover { transform: translateY(-2px); border-color: #33415a; } +.goal-card.achieved { border-color: rgba(46, 204, 113, 0.6); } +.goal-card .goal-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; } +.goal-card h3 { font-size: 15px; font-weight: 600; } +.goal-card .goal-desc { font-size: 12.5px; color: var(--muted); line-height: 1.45; flex: 1; } +.goal-card .goal-value { + font-family: 'Rajdhani', sans-serif; + font-size: 26px; + font-weight: 700; + color: var(--gold); +} +.goal-card .goal-value .goal-target { font-size: 15px; color: var(--muted); } +.progress-bar { + height: 8px; + background: var(--bg-3); + border-radius: 999px; + overflow: hidden; +} +.progress-bar .fill { + height: 100%; + background: linear-gradient(90deg, var(--blue), #60a5fa); + border-radius: 999px; + transition: width 0.6s ease; +} +.goal-card.achieved .progress-bar .fill { background: linear-gradient(90deg, var(--green), #58e08a); } +.badge { + font-size: 10.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 3px 8px; + border-radius: 999px; + white-space: nowrap; +} +.badge.ok { background: rgba(46,204,113,0.15); color: var(--green); } +.badge.achieved { background: linear-gradient(135deg, #f0b429, #d35400); color: #10131a; } +.badge.pending { background: rgba(245,158,11,0.15); color: var(--orange); } +.badge.muted { background: rgba(139,148,167,0.15); color: var(--muted); } +.badge.done { + background: rgba(46, 204, 113, 0.15); + color: var(--green); + border: 1px solid rgba(46, 204, 113, 0.4); +} + +.class-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; +} +.class-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.35); border-color: #33415a; } +.class-head { display: flex; align-items: center; gap: 14px; border-bottom: 1px solid var(--border); padding-bottom: 14px; } +.class-logo { + width: 50px; height: 50px; + border-radius: 10px; + background: var(--bg-3); + object-fit: contain; + border: 1px solid var(--border); + padding: 4px; + flex-shrink: 0; +} +.class-logo-empty { display: grid; place-items: center; color: var(--muted); font-weight: 700; } +.class-name { font-family: 'Rajdhani', sans-serif; font-size: 22px; font-weight: 700; line-height: 1.1; } +.class-count { font-size: 12.5px; color: var(--muted); } +.level-bubble { + margin-left: auto; + background: var(--gold); + color: #0c0e14; + font-family: 'Rajdhani', sans-serif; + font-weight: 700; + font-size: 14px; + padding: 3px 12px; + border-radius: 999px; + white-space: nowrap; + flex-shrink: 0; +} +.class-chars { display: flex; flex-direction: column; gap: 12px; width: 100%; } +.class-empty { color: var(--muted); font-size: 14px; text-align: center; padding: 14px 0; border: 1px dashed var(--border); border-radius: 8px; } + +.class-strip { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 12px; +} +.class-icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex: 0 1 calc((100% - 6 * 12px) / 7); + aspect-ratio: 1; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 14px; + padding: 12px; + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} +.class-icon:hover { transform: translateY(-2px); border-color: var(--class-color, #33415a); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); } +.class-icon-img { width: 100%; height: 100%; object-fit: contain; pointer-events: none; } +.pastille { + position: absolute; + top: -6px; + right: -6px; + display: flex; + gap: 3px; + background: rgba(12, 14, 20, 0.9); + border: 1px solid var(--border); + border-radius: 8px; + padding: 3px; + pointer-events: none; +} +.pastille-icon { + width: 22px; + height: 22px; + border-radius: 5px; + object-fit: cover; + border: 1px solid var(--border); + background: var(--bg-3); +} +.class-modal { max-width: 760px; width: 100%; } +.class-modal-head { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + flex-shrink: 0; + padding-bottom: 14px; + border-bottom: 1px solid var(--border); + margin-bottom: 14px; +} +.class-modal-logo { width: 64px; height: 64px; object-fit: contain; } +.spec-pill { + display: flex; + align-items: center; + gap: 6px; + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px 10px 4px 4px; + font-size: 13px; + font-weight: 600; + font-family: inherit; + color: var(--text); + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} +.spec-pill:hover { border-color: var(--gold); } +.spec-pill-btn.on { border-color: var(--gold); background: rgba(240, 180, 41, 0.12); } +.spec-pill .spec-icon { width: 24px; height: 24px; } +.title-mini { + width: 18px; + height: 18px; + border-radius: 4px; + object-fit: cover; + border: 1px solid var(--border); + margin-left: 6px; + vertical-align: -3px; +} +.class-modal-hint { font-size: 13px; color: var(--muted); text-align: center; padding: 6px 0 2px; } +.class-modal { + max-height: 92vh; + display: flex; + flex-direction: column; +} +.class-modal-chars { + display: flex; + flex-direction: column; + gap: 12px; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 4px 4px 8px; + overscroll-behavior: contain; +} + +.class-char { + position: relative; + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 14px; + padding: 16px 16px 14px; + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; +} +.class-char::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 3px; + border-radius: 14px 14px 0 0; + background: linear-gradient(90deg, var(--class-color, var(--muted)) 0%, var(--class-color, var(--muted)) 30%, transparent 100%); + opacity: 0.85; +} +.class-char:hover { transform: translateY(-2px); box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35); border-color: var(--class-color, #33415a); } + +.wm-card { padding: 14px; align-items: center; cursor: default; } +.wm-specs-top { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; } +.wm-img { + position: relative; + flex-shrink: 0; + width: 100%; + height: 400px; + border: none; + border-radius: 14px; + overflow: hidden; + background: + radial-gradient(500px 300px at 50% 0%, rgba(240, 180, 41, 0.08), transparent 70%), + linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(10, 12, 18, 0.4)), + var(--bg-2); + cursor: default; + padding: 0; + display: block; + transition: box-shadow 0.15s ease; +} +.wm-img:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); } +.wm-img img { width: 100%; height: 100%; object-fit: contain; display: block; } +.wm-img-label { + position: absolute; + bottom: 14px; + left: 50%; + transform: translateX(-50%); + padding: 7px 16px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--text); + background: rgba(10, 12, 18, 0.82); + border: 1px solid var(--class-color, var(--border)); + white-space: nowrap; + transition: background 0.15s ease, color 0.15s ease; +} +.wm-img:hover .wm-img-label { background: var(--class-color, var(--gold)); color: #10131a; } +.wm-img-empty { + display: grid; + place-items: center; + width: 100%; + height: 100%; + font-size: 64px; + font-weight: 700; + font-family: 'Rajdhani', sans-serif; + color: var(--class-color, var(--text)); +} +.wm-info { display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; } +.wm-name { font-family: 'Rajdhani', sans-serif; font-size: 26px; font-weight: 700; line-height: 1.05; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: center; } +.wm-meta { font-size: 12.5px; color: var(--muted); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: center; } +.wm-ilvl { + font-size: 12px; + font-weight: 700; + color: var(--gold); + background: rgba(240, 180, 41, 0.12); + border: 1px solid rgba(240, 180, 41, 0.35); + border-radius: 999px; + padding: 2px 9px; + white-space: nowrap; +} +.wm-titles { display: flex; flex-direction: column; align-items: center; gap: 4px; } +.wm-title { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: var(--text); +} +.wm-title img { width: 20px; height: 20px; border-radius: 4px; object-fit: cover; border: 1px solid var(--border); } +.wm-title b { font-weight: 700; color: var(--gold); } +.wm-specs { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; } +.wm-spec-panel { + display: flex; + align-items: center; + gap: 7px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 7px 12px 7px 7px; + font-size: 13px; + font-weight: 600; + font-family: inherit; + color: var(--text); + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease; +} +.wm-spec-panel .spec-icon { width: 26px; height: 26px; } +.wm-spec-panel:hover { border-color: var(--class-color, var(--gold)); transform: translateY(-1px); } +.wm-spec-panel.on { border-color: var(--class-color, var(--gold)); background: rgba(240, 180, 41, 0.12); } +.wm-detail { display: flex; flex-direction: column; gap: 14px; border-top: 1px solid var(--border); padding-top: 14px; width: 100%; } +.wm-section { padding: 0; border-top: none; } +.char-top { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; } +.char-head { display: flex; align-items: center; gap: 14px; min-width: 0; } +.char-avatar { + width: 54px; height: 54px; + border-radius: 13px; + object-fit: cover; + background: var(--bg-3); + border: 2px solid var(--class-color, var(--border)); + box-shadow: 0 0 0 3px var(--bg-2), 0 0 16px rgba(0, 0, 0, 0.45); + flex-shrink: 0; +} +.char-name { font-family: 'Rajdhani', sans-serif; font-size: 22px; font-weight: 700; line-height: 1.05; white-space: nowrap; } +.char-link { color: var(--gold); text-decoration: none; } +.char-link:hover { text-decoration: underline; } +.char-meta { font-size: 12.5px; color: var(--muted); margin-top: 4px; display: flex; align-items: center; gap: 6px; } +.char-body { display: flex; flex-direction: column; gap: 14px; border-top: 1px solid var(--border); padding-top: 14px; } +.char-ilvl { + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; + background: linear-gradient(180deg, rgba(240, 180, 41, 0.16), rgba(240, 180, 41, 0.05)); + border: 1px solid rgba(240, 180, 41, 0.35); + border-radius: 12px; + padding: 8px 14px; + white-space: nowrap; flex-shrink: 0; + min-width: 74px; +} +.char-ilvl b { color: var(--gold); font-weight: 700; font-size: 18px; font-family: 'Rajdhani', sans-serif; line-height: 1; } +.pvp-table { display: flex; flex-direction: column; gap: 5px; min-width: 220px; } +.pvp-row { display: grid; grid-template-columns: 1fr 72px 72px; gap: 6px; align-items: center; } +.pvp-row span { padding: 5px 10px; border-radius: 8px; background: var(--bg-2); font-size: 13px; } +.pvp-row span:not(.pvp-bracket) { text-align: right; font-variant-numeric: tabular-nums; } +.val-season { color: var(--text); } +.val-max { color: var(--gold); font-weight: 600; } +.pvp-bracket { + display: flex; align-items: center; gap: 8px; + color: var(--text); + font-weight: 600; + white-space: nowrap; + background: transparent !important; +} +.pvp-bracket::before { + content: ''; + width: 8px; height: 8px; border-radius: 50%; + flex-shrink: 0; + background: var(--brk, var(--muted)); + box-shadow: 0 0 8px var(--brk, transparent); +} +.brk-ss { --brk: #37e0ff; } +.brk-blitz { --brk: #ffb347; } +.brk-2v2 { --brk: #5aa9ff; } +.brk-3v3 { --brk: #ff5c5c; } +.brk-rbg { --brk: #7ed957; } +.pvp-head span { background: transparent; color: var(--muted); font-size: 10.5px; padding: 0 10px 2px; text-transform: uppercase; letter-spacing: 1px; } +.pvp-head span:not(.pvp-bracket) { text-align: right; } +.pvp-head .pvp-bracket::before { display: none; } +.pvp-tables { display: flex; flex-direction: column; gap: 12px; min-width: 0; } +.pvp-spec { + background: var(--bg-2); + border: 1px solid var(--border); + border-left: 3px solid var(--class-color, var(--muted)); + border-radius: 10px; + padding: 10px 12px 8px; + display: flex; + flex-direction: column; + gap: 8px; +} +.pvp-spec-name { + display: flex; + align-items: center; + gap: 8px; + font-family: 'Rajdhani', sans-serif; + font-size: 16px; + font-weight: 700; + color: var(--gold); + letter-spacing: 0.5px; +} +.spec-icon { + width: 24px; + height: 24px; + border-radius: 6px; + object-fit: cover; + border: 1px solid var(--border); + background: var(--bg-3); + flex-shrink: 0; +} +.pvp-section h4 { + font-family: 'Rajdhani', sans-serif; + font-size: 15px; + font-weight: 700; + color: var(--gold); + letter-spacing: 0.5px; + margin: 0 0 8px; + border-bottom: 1px solid var(--border); + padding-bottom: 4px; +} +.brk-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(185px, 1fr)); gap: 10px; } +.brk-block { + background: var(--bg-2); + border: 1px solid var(--border); + border-top: 2px solid var(--brk, var(--muted)); + border-radius: 10px; + padding: 10px 12px 9px; + display: flex; + flex-direction: column; + gap: 8px; + transition: border-color 0.15s ease; +} +.brk-block:hover { border-color: var(--brk, #33415a); } +.brk-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.brk-name { font-family: 'Rajdhani', sans-serif; font-size: 15px; font-weight: 700; color: var(--brk, var(--muted)); } +.brk-tier { + font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; + color: var(--brk, var(--muted)); + border: 1px solid var(--brk, var(--muted)); + border-radius: 999px; + padding: 1px 8px; + opacity: 0.95; +} +.brk-record { + margin-left: auto; + font-size: 10px; font-weight: 700; color: var(--green); + border: 1px solid rgba(46, 204, 113, 0.5); + background: rgba(46, 204, 113, 0.12); + border-radius: 999px; + padding: 1px 7px; + animation: pulse-green 1.8s infinite; +} +@keyframes pulse-green { + 0% { box-shadow: 0 0 0 0 rgba(46, 204, 113, 0.45); } + 70% { box-shadow: 0 0 0 7px rgba(46, 204, 113, 0); } + 100% { box-shadow: 0 0 0 0 rgba(46, 204, 113, 0); } +} +.brk-body { display: grid; grid-template-columns: repeat(2, 1fr); gap: 5px 10px; } +.brk-cell { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; font-size: 12px; min-width: 0; } +.brk-cell span { color: var(--muted); font-size: 11px; white-space: nowrap; } +.brk-cell b { color: var(--text); font-variant-numeric: tabular-nums; font-weight: 600; } +.brk-cell b.val-max { color: var(--gold); } +.brk-rating { font-family: 'Rajdhani', sans-serif; font-size: 24px; font-weight: 700; line-height: 1; color: var(--text); display: flex; align-items: baseline; gap: 4px; } +.brk-current { color: var(--text); } +.brk-maxsep { color: var(--muted); font-weight: 400; } +.brk-max { color: var(--gold); font-size: 15px; } +.brk-sub { font-size: 11px; color: var(--muted); margin-top: -3px; } +.brk-tier.muted { color: var(--muted); border-color: var(--border); } +.brk-bar { + height: 7px; + border-radius: 999px; + background: rgba(231, 76, 60, 0.22); + border: 1px solid rgba(231, 76, 60, 0.25); + overflow: hidden; + position: relative; +} +.brk-bar.empty { background: var(--bg-3); border-color: var(--border); } +.brk-bar-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, rgba(46, 204, 113, 0.7), var(--green)); + box-shadow: 0 0 8px rgba(46, 204, 113, 0.4); + transition: width 0.3s ease; +} +.brk-wl { font-size: 12.5px; color: var(--text); font-variant-numeric: tabular-nums; } +.brk-wl b { color: var(--green); font-weight: 700; } +.brk-games { font-size: 11px; color: var(--muted); } +.char-detail-hint { font-size: 10.5px; color: var(--muted); text-align: center; white-space: nowrap; opacity: 0.85; } +.glad-class-row { display: flex; flex-direction: column; align-items: center; gap: 4px; } +.glad-class-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.6px; } + +.goal-class { + position: relative; + display: inline-block; + filter: grayscale(0.85) brightness(0.55); + opacity: 0.6; + transition: filter 0.15s ease, opacity 0.15s ease; +} +.goal-class.done { filter: none; opacity: 1; } +.goal-check { + position: absolute; + top: -5px; right: -5px; + width: 16px; height: 16px; + border-radius: 50%; + background: var(--green); + color: #06120b; + font-size: 10px; font-weight: 800; + display: grid; place-items: center; + border: 1.5px solid var(--bg); + line-height: 1; +} + +.session-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-top: 6px; } +.session-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} +.session-head { + display: flex; + justify-content: space-between; + align-items: center; + font-family: 'Rajdhani', sans-serif; + font-size: 16px; + font-weight: 700; +} +.session-today { font-size: 13px; font-weight: 600; color: var(--muted); } +.session-today em { color: var(--gold); font-style: normal; } +.session-btns { display: flex; gap: 8px; } +.session-btns .btn { flex: 1; padding: 9px; font-size: 13px; font-weight: 700; } +.session-win { border-color: rgba(46, 204, 113, 0.45) !important; color: var(--green) !important; } +.session-loss { border-color: rgba(231, 76, 60, 0.45) !important; color: var(--red) !important; } +.session-week { font-size: 12px; } +.session-undo { margin-top: 12px; } + +#pvp-specs { display: flex; flex-direction: column; gap: 14px; margin-top: 14px; } +.char-sets { display: flex; gap: 8px; } +.stat-chip { + flex: 1; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 12px; + font-size: 12.5px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 10px; + white-space: nowrap; +} +.chip-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; } +.stat-chip b { color: var(--gold); font-weight: 600; font-size: 15px; font-family: 'Rajdhani', sans-serif; } +.stat-max { color: var(--muted); font-size: 11px; font-weight: 500; margin-left: 4px; } +.stat-max.on { color: var(--gold); } +.char-foot { display: flex; align-items: center; gap: 14px; flex-shrink: 0; } +.char-sources { font-size: 11px; color: var(--muted); text-align: right; } +.btn-3d { + background: var(--bg-3); + border: 1px solid var(--border); + color: var(--text); + font-size: 12.5px; + padding: 6px 12px; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; +} +.btn-3d:hover { border-color: var(--gold); color: var(--gold); } + +.loading { color: var(--muted); font-size: 14px; padding: 20px 0; } + +.modal-backdrop { + position: fixed; inset: 0; + background: rgba(0,0,0,0.75); + backdrop-filter: blur(4px); + display: flex; align-items: center; justify-content: center; + z-index: 50; + padding: 20px; +} +.modal-backdrop[hidden] { display: none; } +.modal { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 16px; + width: min(560px, 100%); + max-height: 92vh; + overflow: auto; + position: relative; +} +.modal-close { + position: absolute; top: 10px; right: 14px; + background: none; border: none; + color: var(--muted); font-size: 26px; + cursor: pointer; z-index: 2; +} +.modal-close:hover { color: var(--text); } +.modal-head { + display: flex; align-items: center; gap: 12px; + padding: 18px 18px 0; +} +.modal-avatar { width: 48px; height: 48px; border-radius: 10px; border: 1px solid var(--border); object-fit: cover; } +.modal-head h3 { font-family: 'Rajdhani', sans-serif; font-size: 22px; } +.modal-head p { font-size: 13px; color: var(--muted); } +.pvp-form { padding: 16px 18px 18px; } +.pvp-form .admin-table { margin-top: 12px; } +.pvp-actions { display: flex; align-items: center; gap: 12px; margin-top: 14px; } +.pvp-actions .hint { font-size: 12px; } +.modal-art { + width: 100%; + height: 360px; + margin-top: 12px; + position: relative; + display: flex; + align-items: center; + justify-content: center; + background: + radial-gradient(400px 200px at 50% 0%, rgba(240, 180, 41, 0.08), transparent 70%), + var(--bg-3); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + overflow: hidden; +} +.modal-art img { max-width: 100%; max-height: 100%; object-fit: contain; } +.modal-error { + margin: 12px 18px 0; + padding: 12px; + background: rgba(231,76,60,0.12); + border: 1px solid rgba(231,76,60,0.4); + border-radius: 8px; + color: #ff8f87; + font-size: 13px; +} +.modal-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 8px; + padding: 14px 18px 18px; +} +.modal-stats .stat-chip { justify-content: space-between; background: var(--bg-3); } + +/* Buttons & forms */ +.btn { + background: var(--bg-3); + border: 1px solid var(--border); + color: var(--text); + font-size: 13px; + font-weight: 500; + padding: 8px 14px; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; +} +.btn:hover { border-color: #3a4a68; } +.btn-primary { background: linear-gradient(135deg, var(--gold), #d35400); color: #10131a; border: none; font-weight: 600; } +.btn-primary:hover { filter: brightness(1.1); } +.btn-ghost { background: none; border-color: var(--border); color: var(--muted); } +.btn-ghost:hover { color: var(--text); border-color: var(--muted); } + +input, select { + background: var(--bg-3); + border: 1px solid var(--border); + color: var(--text); + font-size: 14px; + padding: 9px 12px; + border-radius: 8px; + outline: none; +} +input:focus, select:focus { border-color: var(--blue); } + +/* Admin */ +.admin-body .container { max-width: 1100px; } +.login-box { + max-width: 380px; + margin: 60px auto; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 14px; + padding: 26px; +} +.login-box h2 { font-family: 'Rajdhani', sans-serif; margin-bottom: 16px; } +.login-box form { display: flex; flex-direction: column; gap: 12px; } +.form-error { color: #ff8f87; font-size: 13px; margin-top: 10px; } + +.tabs { display: flex; gap: 8px; margin-bottom: 20px; border-bottom: 1px solid var(--border); padding-bottom: 12px; flex-wrap: wrap; } +.tab-btn { + background: none; border: 1px solid var(--border); + color: var(--muted); padding: 8px 16px; + border-radius: 8px; cursor: pointer; font-size: 14px; +} +.tab-btn.active { background: var(--bg-3); color: var(--text); border-color: var(--gold); } +.tab-panel { display: none; } +.tab-panel.active { display: block; } +.panel-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; gap: 10px; flex-wrap: wrap; } +.panel-toolbar h3 { font-family: 'Rajdhani', sans-serif; font-size: 19px; } +.toolbar-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +#fetch-status { font-size: 12px; color: var(--muted); } +#fetch-status b { color: var(--gold); font-weight: 600; } + +.form-row { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; } +.form-grid label { display: block; font-size: 12.5px; color: var(--muted); margin-bottom: 6px; } +.form-grid input { width: 100%; } + +.admin-table { width: 100%; border-collapse: collapse; font-size: 13.5px; } +.admin-table th, .admin-table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); } +.admin-table th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; } +.admin-table tr:hover td { background: rgba(255,255,255,0.02); } +.table-actions { display: flex; gap: 6px; } +.mini-btn { font-size: 12px; padding: 4px 10px; } +.mini-num { width: 64px; padding: 6px 8px; font-size: 13px; } +.mini-url { width: 200px; padding: 6px 8px; font-size: 12.5px; } +.mini-select { padding: 6px 8px; font-size: 13px; max-width: 160px; } +.del-btn { color: var(--red); background: none; border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; cursor: pointer; } +.del-btn:hover { border-color: var(--red); } +.span-2 { grid-column: span 2; } +.class-select { display: flex; flex-wrap: wrap; gap: 6px; } +.class-chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px 4px 6px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-3); + cursor: pointer; + font-size: 12px; + color: var(--muted); + transition: all 0.15s; + user-select: none; +} +.class-chip img { width: 22px; height: 22px; object-fit: contain; } +.class-chip:hover { border-color: var(--gold); } +.class-chip.on { border-color: var(--gold); background: rgba(240, 180, 41, 0.12); color: var(--text); } + +.title-tick { display: inline-flex; align-items: center; cursor: pointer; margin-right: 4px; position: relative; } +.title-tick input { position: absolute; opacity: 0; pointer-events: none; } +.title-tick img { + width: 26px; + height: 26px; + border-radius: 6px; + object-fit: cover; + border: 2px solid var(--border); + opacity: 0.35; + transition: opacity 0.15s ease, border-color 0.15s ease; +} +.title-tick input:checked + img { opacity: 1; border-color: var(--gold); } + +.settings-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; + margin-bottom: 16px; +} +.settings-card .btn { margin-right: 8px; margin-top: 4px; } +.hint { font-size: 12.5px; color: var(--muted); line-height: 1.5; display: block; } + +.admin-titles { display: flex; flex-direction: column; gap: 5px; min-width: 150px; } +.at-entry { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border); + border-radius: 7px; + padding: 3px 6px; + font-size: 12px; + background: var(--bg-2); +} +.at-entry img { width: 18px; height: 18px; border-radius: 3px; object-fit: cover; } +.at-entry b { font-size: 11px; letter-spacing: 0.4px; } +.at-season-label { display: inline; font-size: 11px; } +.at-entry.at-glad { border-color: rgba(255, 176, 32, 0.45); } +.at-entry.at-legend { border-color: rgba(191, 85, 236, 0.45); } +.at-del { + border: none; + background: none; + color: var(--muted); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0 1px; + margin-left: auto; +} +.at-del:hover { color: var(--red); } +.at-add { display: flex; align-items: center; gap: 4px; } +.at-add .mini-select { width: auto; } +.at-season { width: 86px; } +.title-tick { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; } +.title-tick img { width: 20px; height: 20px; border-radius: 4px; object-fit: cover; } +.settings-card h3 { font-family: 'Rajdhani', sans-serif; margin: 0 0 12px; } +.admin-goals .goal-card h3 { font-size: 13.5px; } +.admin-goals .goal-card { gap: 6px; padding: 12px; } +.admin-goals input { width: 100%; font-size: 12.5px; padding: 6px 9px; } +.admin-goals label { font-size: 11px; color: var(--muted); display: block; margin-bottom: 3px; } + +.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; } +.status-dot.ok { background: var(--green); } +.status-dot.error { background: var(--red); } + +.char-top-right { display: flex; flex-direction: column; align-items: center; gap: 8px; flex-shrink: 0; } +.char-detail-btn { font-size: 12px; padding: 5px 10px; white-space: nowrap; border-radius: 8px; } + +.char-detail-modal { width: min(720px, 100%); } +.char-detail-modal .modal-art { height: 420px; } +.modal-section { padding: 14px 18px; border-top: 1px solid var(--border); } +.modal-section-title { + font-family: 'Rajdhani', sans-serif; + font-size: 16px; + font-weight: 700; + color: var(--gold); + text-transform: uppercase; + letter-spacing: 0.6px; + margin-bottom: 10px; +} +.modal-gear { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 8px; +} +.gear-item { + display: flex; + align-items: center; + gap: 10px; + background: var(--bg-3); + border: 1px solid var(--border); + border-left: 3px solid var(--q, var(--text)); + border-radius: 10px; + padding: 7px 10px; + min-width: 0; +} +.gear-icon { + width: 36px; height: 36px; + border-radius: 8px; + flex-shrink: 0; + display: grid; + place-items: center; + overflow: hidden; + background: var(--bg-2); + border: 1px solid var(--border); +} +.gear-icon img { width: 100%; height: 100%; object-fit: cover; display: block; } +.gear-slot-letter { font-weight: 800; color: var(--muted); font-size: 13px; } +.gear-info { display: flex; flex-direction: column; min-width: 0; } +.gear-name { + font-size: 12.5px; + font-weight: 600; + color: var(--q, var(--text)); + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; +} +a.gear-name:hover { text-decoration: underline; } +.gear-ilvl { font-size: 11.5px; color: var(--muted); } +.gear-empty { font-size: 13px; color: var(--muted); padding: 6px 2px; } + +.talent-panel { + position: relative; + border-radius: 12px; + overflow: hidden; + min-height: 180px; + display: grid; + place-items: center; + border: 1px solid var(--border); + background: var(--bg-3); +} +.talent-panel.no-bg { min-height: 110px; } +.talent-bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.35; +} +.talent-overlay { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 18px; + text-align: center; + background: rgba(10, 12, 18, 0.65); + border-radius: 12px; +} +.talent-title { font-family: 'Rajdhani', sans-serif; font-size: 18px; font-weight: 700; } +.talent-note { font-size: 12.5px; color: var(--muted); max-width: 380px; } + +.tt-wrap { display: flex; flex-direction: column; gap: 14px; } +.tt-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.wm-copy { + font-family: 'Rajdhani', sans-serif; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 7px 14px; + border-radius: 8px; + border: 1px solid var(--gold); + color: #10131a; + background: linear-gradient(135deg, var(--gold), #d35400); + cursor: pointer; + transition: filter 0.15s ease, transform 0.15s ease; +} +.wm-copy:hover { filter: brightness(1.1); transform: translateY(-1px); } +.tt-block { + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-3); + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} +.tt-head { display: flex; align-items: baseline; gap: 8px; font-size: 14px; padding: 2px 4px; } +.tt-head b { font-family: 'Rajdhani', sans-serif; font-size: 16px; letter-spacing: 0.5px; } +.tt-scroll { overflow-x: auto; padding-bottom: 4px; } +.tt-grid { position: relative; min-width: 100%; } +.tn { + position: absolute; + width: 52px; + height: 52px; + border-radius: 8px; + border: 2px solid transparent; + overflow: hidden; + background: var(--bg-2); + display: grid; + place-items: center; + cursor: help; + transition: transform 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease; +} +.tn img { width: 100%; height: 100%; object-fit: cover; display: block; } +.tn-letter { font-family: 'Rajdhani', sans-serif; font-size: 20px; font-weight: 700; color: var(--muted); } +.tn.on { + border-color: var(--gold); + box-shadow: 0 0 0 2px rgba(245, 166, 35, 0.25), 0 0 14px rgba(245, 166, 35, 0.35); + z-index: 2; +} +.tn.no-icon img { display: none; } +.tn:hover { transform: translateY(-2px) scale(1.05); z-index: 3; } +.tn-rank { + position: absolute; + bottom: -1px; + right: -1px; + min-width: 16px; + height: 16px; + padding: 0 3px; + font-size: 10.5px; + font-weight: 700; + line-height: 16px; + text-align: center; + color: #10131a; + background: linear-gradient(135deg, var(--gold), #d35400); + border-radius: 4px 0 0 0; +} + +@media (max-width: 640px) { + .form-grid { grid-template-columns: 1fr; } + .modal-art { height: 260px; } + .char-detail-modal .modal-art { height: 320px; } + .char-top-right { flex-direction: row; justify-content: space-between; } + .modal-gear { grid-template-columns: 1fr; } + .char-top { flex-direction: column; align-items: stretch; } + .char-foot { justify-content: space-between; } + .header-inner { flex-wrap: wrap; gap: 10px; justify-content: center; } + .header-classes { max-width: 100%; order: 3; } +} diff --git a/backend/public/images/classes/DK.png b/backend/public/images/classes/DK.png new file mode 100644 index 0000000..1a3a589 Binary files /dev/null and b/backend/public/images/classes/DK.png differ diff --git a/backend/public/images/classes/Druid.png b/backend/public/images/classes/Druid.png new file mode 100644 index 0000000..ec2c179 Binary files /dev/null and b/backend/public/images/classes/Druid.png differ diff --git a/backend/public/images/classes/Hunt.png b/backend/public/images/classes/Hunt.png new file mode 100644 index 0000000..59f58b0 Binary files /dev/null and b/backend/public/images/classes/Hunt.png differ diff --git a/backend/public/images/classes/Lock.png b/backend/public/images/classes/Lock.png new file mode 100644 index 0000000..3cc7fe8 Binary files /dev/null and b/backend/public/images/classes/Lock.png differ diff --git a/backend/public/images/classes/Mage.png b/backend/public/images/classes/Mage.png new file mode 100644 index 0000000..40e3a0f Binary files /dev/null and b/backend/public/images/classes/Mage.png differ diff --git a/backend/public/images/classes/Pal.png b/backend/public/images/classes/Pal.png new file mode 100644 index 0000000..7fd3379 Binary files /dev/null and b/backend/public/images/classes/Pal.png differ diff --git a/backend/public/images/classes/Priest.png b/backend/public/images/classes/Priest.png new file mode 100644 index 0000000..ad32d21 Binary files /dev/null and b/backend/public/images/classes/Priest.png differ diff --git a/backend/public/images/classes/Rogue.png b/backend/public/images/classes/Rogue.png new file mode 100644 index 0000000..609f965 Binary files /dev/null and b/backend/public/images/classes/Rogue.png differ diff --git a/backend/public/images/classes/Sham.png b/backend/public/images/classes/Sham.png new file mode 100644 index 0000000..12ff776 Binary files /dev/null and b/backend/public/images/classes/Sham.png differ diff --git a/backend/public/images/classes/War.png b/backend/public/images/classes/War.png new file mode 100644 index 0000000..aaac3e0 Binary files /dev/null and b/backend/public/images/classes/War.png differ diff --git a/backend/public/images/classes/dh.png b/backend/public/images/classes/dh.png new file mode 100644 index 0000000..322b1a2 Binary files /dev/null and b/backend/public/images/classes/dh.png differ diff --git a/backend/public/images/classes/evoker.png b/backend/public/images/classes/evoker.png new file mode 100644 index 0000000..92aa550 Binary files /dev/null and b/backend/public/images/classes/evoker.png differ diff --git a/backend/public/images/classes/monk.png b/backend/public/images/classes/monk.png new file mode 100644 index 0000000..66e9f0b Binary files /dev/null and b/backend/public/images/classes/monk.png differ diff --git a/backend/public/images/icons/icon-180.png b/backend/public/images/icons/icon-180.png new file mode 100644 index 0000000..4047ce2 Binary files /dev/null and b/backend/public/images/icons/icon-180.png differ diff --git a/backend/public/images/icons/icon-192.png b/backend/public/images/icons/icon-192.png new file mode 100644 index 0000000..79d7094 Binary files /dev/null and b/backend/public/images/icons/icon-192.png differ diff --git a/backend/public/images/icons/icon-512.png b/backend/public/images/icons/icon-512.png new file mode 100644 index 0000000..824fc15 Binary files /dev/null and b/backend/public/images/icons/icon-512.png differ diff --git a/backend/public/images/icons/icon-96.png b/backend/public/images/icons/icon-96.png new file mode 100644 index 0000000..644c836 Binary files /dev/null and b/backend/public/images/icons/icon-96.png differ diff --git a/backend/public/images/logo.png b/backend/public/images/logo.png new file mode 100644 index 0000000..c97b834 Binary files /dev/null and b/backend/public/images/logo.png differ diff --git a/backend/public/images/logo5.png b/backend/public/images/logo5.png new file mode 100644 index 0000000..6ee5ffa Binary files /dev/null and b/backend/public/images/logo5.png differ diff --git a/backend/public/images/specs/ability_demonhunter_specdps.jpg b/backend/public/images/specs/ability_demonhunter_specdps.jpg new file mode 100644 index 0000000..8ca6570 Binary files /dev/null and b/backend/public/images/specs/ability_demonhunter_specdps.jpg differ diff --git a/backend/public/images/specs/ability_demonhunter_spectank.jpg b/backend/public/images/specs/ability_demonhunter_spectank.jpg new file mode 100644 index 0000000..ba7c765 Binary files /dev/null and b/backend/public/images/specs/ability_demonhunter_spectank.jpg differ diff --git a/backend/public/images/specs/ability_druid_catform.jpg b/backend/public/images/specs/ability_druid_catform.jpg new file mode 100644 index 0000000..c5c6374 Binary files /dev/null and b/backend/public/images/specs/ability_druid_catform.jpg differ diff --git a/backend/public/images/specs/ability_hunter_bestialdiscipline.jpg b/backend/public/images/specs/ability_hunter_bestialdiscipline.jpg new file mode 100644 index 0000000..3a94632 Binary files /dev/null and b/backend/public/images/specs/ability_hunter_bestialdiscipline.jpg differ diff --git a/backend/public/images/specs/ability_hunter_camouflage.jpg b/backend/public/images/specs/ability_hunter_camouflage.jpg new file mode 100644 index 0000000..f1c6bb2 Binary files /dev/null and b/backend/public/images/specs/ability_hunter_camouflage.jpg differ diff --git a/backend/public/images/specs/ability_hunter_focusedaim.jpg b/backend/public/images/specs/ability_hunter_focusedaim.jpg new file mode 100644 index 0000000..27c4931 Binary files /dev/null and b/backend/public/images/specs/ability_hunter_focusedaim.jpg differ diff --git a/backend/public/images/specs/ability_paladin_shieldofthetemplar.jpg b/backend/public/images/specs/ability_paladin_shieldofthetemplar.jpg new file mode 100644 index 0000000..9f60780 Binary files /dev/null and b/backend/public/images/specs/ability_paladin_shieldofthetemplar.jpg differ diff --git a/backend/public/images/specs/ability_racial_bearform.jpg b/backend/public/images/specs/ability_racial_bearform.jpg new file mode 100644 index 0000000..2bfa3b2 Binary files /dev/null and b/backend/public/images/specs/ability_racial_bearform.jpg differ diff --git a/backend/public/images/specs/ability_rogue_deadlybrew.jpg b/backend/public/images/specs/ability_rogue_deadlybrew.jpg new file mode 100644 index 0000000..d116dc7 Binary files /dev/null and b/backend/public/images/specs/ability_rogue_deadlybrew.jpg differ diff --git a/backend/public/images/specs/ability_rogue_waylay.jpg b/backend/public/images/specs/ability_rogue_waylay.jpg new file mode 100644 index 0000000..73bd7c1 Binary files /dev/null and b/backend/public/images/specs/ability_rogue_waylay.jpg differ diff --git a/backend/public/images/specs/ability_stealth.jpg b/backend/public/images/specs/ability_stealth.jpg new file mode 100644 index 0000000..87ac003 Binary files /dev/null and b/backend/public/images/specs/ability_stealth.jpg differ diff --git a/backend/public/images/specs/ability_warrior_defensivestance.jpg b/backend/public/images/specs/ability_warrior_defensivestance.jpg new file mode 100644 index 0000000..d171741 Binary files /dev/null and b/backend/public/images/specs/ability_warrior_defensivestance.jpg differ diff --git a/backend/public/images/specs/ability_warrior_innerrage.jpg b/backend/public/images/specs/ability_warrior_innerrage.jpg new file mode 100644 index 0000000..313d1ff Binary files /dev/null and b/backend/public/images/specs/ability_warrior_innerrage.jpg differ diff --git a/backend/public/images/specs/ability_warrior_savageblow.jpg b/backend/public/images/specs/ability_warrior_savageblow.jpg new file mode 100644 index 0000000..9d508a4 Binary files /dev/null and b/backend/public/images/specs/ability_warrior_savageblow.jpg differ diff --git a/backend/public/images/specs/classicon_demonhunter_void.jpg b/backend/public/images/specs/classicon_demonhunter_void.jpg new file mode 100644 index 0000000..d00eba8 Binary files /dev/null and b/backend/public/images/specs/classicon_demonhunter_void.jpg differ diff --git a/backend/public/images/specs/classicon_evoker_augmentation.jpg b/backend/public/images/specs/classicon_evoker_augmentation.jpg new file mode 100644 index 0000000..54fe758 Binary files /dev/null and b/backend/public/images/specs/classicon_evoker_augmentation.jpg differ diff --git a/backend/public/images/specs/classicon_evoker_devastation.jpg b/backend/public/images/specs/classicon_evoker_devastation.jpg new file mode 100644 index 0000000..18ac1eb Binary files /dev/null and b/backend/public/images/specs/classicon_evoker_devastation.jpg differ diff --git a/backend/public/images/specs/classicon_evoker_preservation.jpg b/backend/public/images/specs/classicon_evoker_preservation.jpg new file mode 100644 index 0000000..e4fde3a Binary files /dev/null and b/backend/public/images/specs/classicon_evoker_preservation.jpg differ diff --git a/backend/public/images/specs/spell_deathknight_bloodpresence.jpg b/backend/public/images/specs/spell_deathknight_bloodpresence.jpg new file mode 100644 index 0000000..10ce777 Binary files /dev/null and b/backend/public/images/specs/spell_deathknight_bloodpresence.jpg differ diff --git a/backend/public/images/specs/spell_deathknight_frostpresence.jpg b/backend/public/images/specs/spell_deathknight_frostpresence.jpg new file mode 100644 index 0000000..646a33e Binary files /dev/null and b/backend/public/images/specs/spell_deathknight_frostpresence.jpg differ diff --git a/backend/public/images/specs/spell_deathknight_unholypresence.jpg b/backend/public/images/specs/spell_deathknight_unholypresence.jpg new file mode 100644 index 0000000..30697d7 Binary files /dev/null and b/backend/public/images/specs/spell_deathknight_unholypresence.jpg differ diff --git a/backend/public/images/specs/spell_fire_firebolt02.jpg b/backend/public/images/specs/spell_fire_firebolt02.jpg new file mode 100644 index 0000000..69a63dc Binary files /dev/null and b/backend/public/images/specs/spell_fire_firebolt02.jpg differ diff --git a/backend/public/images/specs/spell_frost_frostbolt02.jpg b/backend/public/images/specs/spell_frost_frostbolt02.jpg new file mode 100644 index 0000000..9af3698 Binary files /dev/null and b/backend/public/images/specs/spell_frost_frostbolt02.jpg differ diff --git a/backend/public/images/specs/spell_holy_auraoflight.jpg b/backend/public/images/specs/spell_holy_auraoflight.jpg new file mode 100644 index 0000000..44c6b75 Binary files /dev/null and b/backend/public/images/specs/spell_holy_auraoflight.jpg differ diff --git a/backend/public/images/specs/spell_holy_guardianspirit.jpg b/backend/public/images/specs/spell_holy_guardianspirit.jpg new file mode 100644 index 0000000..11191da Binary files /dev/null and b/backend/public/images/specs/spell_holy_guardianspirit.jpg differ diff --git a/backend/public/images/specs/spell_holy_holybolt.jpg b/backend/public/images/specs/spell_holy_holybolt.jpg new file mode 100644 index 0000000..640f519 Binary files /dev/null and b/backend/public/images/specs/spell_holy_holybolt.jpg differ diff --git a/backend/public/images/specs/spell_holy_magicalsentry.jpg b/backend/public/images/specs/spell_holy_magicalsentry.jpg new file mode 100644 index 0000000..74b6dde Binary files /dev/null and b/backend/public/images/specs/spell_holy_magicalsentry.jpg differ diff --git a/backend/public/images/specs/spell_holy_powerwordshield.jpg b/backend/public/images/specs/spell_holy_powerwordshield.jpg new file mode 100644 index 0000000..102a824 Binary files /dev/null and b/backend/public/images/specs/spell_holy_powerwordshield.jpg differ diff --git a/backend/public/images/specs/spell_monk_brewmaster_spec.jpg b/backend/public/images/specs/spell_monk_brewmaster_spec.jpg new file mode 100644 index 0000000..7a2245d Binary files /dev/null and b/backend/public/images/specs/spell_monk_brewmaster_spec.jpg differ diff --git a/backend/public/images/specs/spell_monk_mistweaver_spec.jpg b/backend/public/images/specs/spell_monk_mistweaver_spec.jpg new file mode 100644 index 0000000..63805d6 Binary files /dev/null and b/backend/public/images/specs/spell_monk_mistweaver_spec.jpg differ diff --git a/backend/public/images/specs/spell_monk_windwalker_spec.jpg b/backend/public/images/specs/spell_monk_windwalker_spec.jpg new file mode 100644 index 0000000..c9c92e2 Binary files /dev/null and b/backend/public/images/specs/spell_monk_windwalker_spec.jpg differ diff --git a/backend/public/images/specs/spell_nature_healingtouch.jpg b/backend/public/images/specs/spell_nature_healingtouch.jpg new file mode 100644 index 0000000..7556bd9 Binary files /dev/null and b/backend/public/images/specs/spell_nature_healingtouch.jpg differ diff --git a/backend/public/images/specs/spell_nature_lightning.jpg b/backend/public/images/specs/spell_nature_lightning.jpg new file mode 100644 index 0000000..0e44c45 Binary files /dev/null and b/backend/public/images/specs/spell_nature_lightning.jpg differ diff --git a/backend/public/images/specs/spell_nature_magicimmunity.jpg b/backend/public/images/specs/spell_nature_magicimmunity.jpg new file mode 100644 index 0000000..a0e57cd Binary files /dev/null and b/backend/public/images/specs/spell_nature_magicimmunity.jpg differ diff --git a/backend/public/images/specs/spell_nature_starfall.jpg b/backend/public/images/specs/spell_nature_starfall.jpg new file mode 100644 index 0000000..d1ddece Binary files /dev/null and b/backend/public/images/specs/spell_nature_starfall.jpg differ diff --git a/backend/public/images/specs/spell_shadow_deathcoil.jpg b/backend/public/images/specs/spell_shadow_deathcoil.jpg new file mode 100644 index 0000000..ad77791 Binary files /dev/null and b/backend/public/images/specs/spell_shadow_deathcoil.jpg differ diff --git a/backend/public/images/specs/spell_shadow_metamorphosis.jpg b/backend/public/images/specs/spell_shadow_metamorphosis.jpg new file mode 100644 index 0000000..f157aa6 Binary files /dev/null and b/backend/public/images/specs/spell_shadow_metamorphosis.jpg differ diff --git a/backend/public/images/specs/spell_shadow_rainoffire.jpg b/backend/public/images/specs/spell_shadow_rainoffire.jpg new file mode 100644 index 0000000..6141dc2 Binary files /dev/null and b/backend/public/images/specs/spell_shadow_rainoffire.jpg differ diff --git a/backend/public/images/specs/spell_shadow_shadowwordpain.jpg b/backend/public/images/specs/spell_shadow_shadowwordpain.jpg new file mode 100644 index 0000000..bcb936b Binary files /dev/null and b/backend/public/images/specs/spell_shadow_shadowwordpain.jpg differ diff --git a/backend/public/images/specs/spell_shaman_improvedstormstrike.jpg b/backend/public/images/specs/spell_shaman_improvedstormstrike.jpg new file mode 100644 index 0000000..11bd59e Binary files /dev/null and b/backend/public/images/specs/spell_shaman_improvedstormstrike.jpg differ diff --git a/backend/public/images/titles/gladiateur.jpg b/backend/public/images/titles/gladiateur.jpg new file mode 100644 index 0000000..2b57789 Binary files /dev/null and b/backend/public/images/titles/gladiateur.jpg differ diff --git a/backend/public/images/titles/legende.webp b/backend/public/images/titles/legende.webp new file mode 100644 index 0000000..dfaa853 Binary files /dev/null and b/backend/public/images/titles/legende.webp differ diff --git a/backend/public/index.html b/backend/public/index.html new file mode 100644 index 0000000..5b57748 --- /dev/null +++ b/backend/public/index.html @@ -0,0 +1,90 @@ + + + + + + Wycz - wow + + + + + + + + + + + + + + + + +
+
+

Objectifs de la Saison 2 Midnight

+
+
Chargement…
+
+
+
+ + + + + + + + + + + diff --git a/backend/public/js/admin.js b/backend/public/js/admin.js new file mode 100644 index 0000000..74d33cf --- /dev/null +++ b/backend/public/js/admin.js @@ -0,0 +1,676 @@ +(function () { + const $ = (sel) => document.querySelector(sel); + const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + + const FR_CLASS_TO_EN = { + 'Guerrier': 'Warrior', 'Paladin': 'Paladin', 'Chasseur': 'Hunter', 'Voleur': 'Rogue', + 'Prêtre': 'Priest', 'Chevalier de la mort': 'Death Knight', 'Chaman': 'Shaman', 'Mage': 'Mage', + 'Démoniste': 'Warlock', 'Moine': 'Monk', 'Druide': 'Druid', 'Chasseur de démons': 'Demon Hunter', + 'Évocateur': 'Evoker' + }; + const PVP_GENERAL = [['ARENA_2v2', '2v2'], ['ARENA_3v3', '3v3'], ['RATED_BG', 'RBG']]; + const PVP_PER_SPEC = [['SOLO_SHUFFLE', 'Solo Shuffle'], ['BLITZ', 'Blitz']]; + const SPECS_BY_CLASS = { + 'Death Knight': [ + { key: 'Blood', fr: 'Sang', icon: 'spell_deathknight_bloodpresence.jpg' }, + { key: 'Frost', fr: 'Givre', icon: 'spell_deathknight_frostpresence.jpg' }, + { key: 'Unholy', fr: 'Impie', icon: 'spell_deathknight_unholypresence.jpg' } + ], + 'Demon Hunter': [ + { key: 'Havoc', fr: 'Dévastation', icon: 'ability_demonhunter_specdps.jpg' }, + { key: 'Vengeance', fr: 'Vengeance', icon: 'ability_demonhunter_spectank.jpg' }, + { key: 'Devoration', fr: 'Dévoration', icon: 'classicon_demonhunter_void.jpg' } + ], + 'Druid': [ + { key: 'Balance', fr: 'Équilibre', icon: 'spell_nature_starfall.jpg' }, + { key: 'Feral', fr: 'Farouche', icon: 'ability_druid_catform.jpg' }, + { key: 'Guardian', fr: 'Gardien', icon: 'ability_racial_bearform.jpg' }, + { key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_healingtouch.jpg' } + ], + 'Evoker': [ + { key: 'Devastation', fr: 'Dévastation', icon: 'classicon_evoker_devastation.jpg' }, + { key: 'Preservation', fr: 'Préservation', icon: 'classicon_evoker_preservation.jpg' }, + { key: 'Augmentation', fr: 'Augmentation', icon: 'classicon_evoker_augmentation.jpg' } + ], + 'Hunter': [ + { key: 'Beast Mastery', fr: 'Maîtrise des bêtes', icon: 'ability_hunter_bestialdiscipline.jpg' }, + { key: 'Marksmanship', fr: 'Précision', icon: 'ability_hunter_focusedaim.jpg' }, + { key: 'Survival', fr: 'Survie', icon: 'ability_hunter_camouflage.jpg' } + ], + 'Mage': [ + { key: 'Arcane', fr: 'Arcanes', icon: 'spell_nature_magicimmunity.jpg' }, + { key: 'Fire', fr: 'Feu', icon: 'spell_fire_firebolt02.jpg' }, + { key: 'Frost', fr: 'Givre', icon: 'spell_frost_frostbolt02.jpg' } + ], + 'Monk': [ + { key: 'Brewmaster', fr: 'Maître brasseur', icon: 'spell_monk_brewmaster_spec.jpg' }, + { key: 'Mistweaver', fr: 'Tisse-brume', icon: 'spell_monk_mistweaver_spec.jpg' }, + { key: 'Windwalker', fr: 'Marche-vent', icon: 'spell_monk_windwalker_spec.jpg' } + ], + 'Paladin': [ + { key: 'Holy', fr: 'Sacré', icon: 'spell_holy_holybolt.jpg' }, + { key: 'Protection', fr: 'Protection', icon: 'ability_paladin_shieldofthetemplar.jpg' }, + { key: 'Retribution', fr: 'Vindicte', icon: 'spell_holy_auraoflight.jpg' } + ], + 'Priest': [ + { key: 'Discipline', fr: 'Discipline', icon: 'spell_holy_powerwordshield.jpg' }, + { key: 'Holy', fr: 'Sacré', icon: 'spell_holy_guardianspirit.jpg' }, + { key: 'Shadow', fr: 'Ombre', icon: 'spell_shadow_shadowwordpain.jpg' } + ], + 'Rogue': [ + { key: 'Assassination', fr: 'Assassinat', icon: 'ability_rogue_deadlybrew.jpg' }, + { key: 'Outlaw', fr: 'Hors-la-loi', icon: 'ability_rogue_waylay.jpg' }, + { key: 'Subtlety', fr: 'Finesse', icon: 'ability_stealth.jpg' } + ], + 'Shaman': [ + { key: 'Elemental', fr: 'Élémentaire', icon: 'spell_nature_lightning.jpg' }, + { key: 'Enhancement', fr: 'Amélioration', icon: 'spell_shaman_improvedstormstrike.jpg' }, + { key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_healingtouch.jpg' } + ], + 'Warlock': [ + { key: 'Affliction', fr: 'Affliction', icon: 'spell_shadow_deathcoil.jpg' }, + { key: 'Demonology', fr: 'Démonologie', icon: 'spell_shadow_metamorphosis.jpg' }, + { key: 'Destruction', fr: 'Destruction', icon: 'spell_shadow_rainoffire.jpg' } + ], + 'Warrior': [ + { key: 'Arms', fr: 'Armes', icon: 'ability_warrior_innerrage.jpg' }, + { key: 'Fury', fr: 'Fureur', icon: 'ability_warrior_savageblow.jpg' }, + { key: 'Protection', fr: 'Protection', icon: 'ability_warrior_defensivestance.jpg' } + ] + }; + const ADMIN_CLASSES = [ + { name: 'Warrior', fr: 'Guerrier', logo: 'War.png' }, + { name: 'Paladin', fr: 'Paladin', logo: 'Pal.png' }, + { name: 'Hunter', fr: 'Chasseur', logo: 'Hunt.png' }, + { name: 'Rogue', fr: 'Voleur', logo: 'Rogue.png' }, + { name: 'Priest', fr: 'Prêtre', logo: 'Priest.png' }, + { name: 'Death Knight', fr: 'Chevalier de la mort', logo: 'DK.png' }, + { name: 'Shaman', fr: 'Chaman', logo: 'Sham.png' }, + { name: 'Mage', fr: 'Mage', logo: 'Mage.png' }, + { name: 'Warlock', fr: 'Démoniste', logo: 'Lock.png' }, + { name: 'Monk', fr: 'Moine', logo: 'monk.png' }, + { name: 'Druid', fr: 'Druide', logo: 'Druid.png' }, + { name: 'Demon Hunter', fr: 'Chasseur de démons', logo: 'dh.png' }, + { name: 'Evoker', fr: 'Évocateur', logo: 'evoker.png' } + ]; + let pvpEditId = null; + + let state = { logged: false }; + + async function checkLogin() { + try { + await api.get('/api/admin/me'); + state.logged = true; + } catch { + state.logged = false; + } + render(); + } + + function render() { + $('#login-view').hidden = state.logged; + $('#admin-view').hidden = !state.logged; + $('#logout-btn').hidden = !state.logged; + if (state.logged) { + loadChars(); + loadGoals(); + loadSettings(); + loadSessions(); + loadLogs(); + } + } + + const SESSION_BRACKETS = [['SOLO_SHUFFLE', 'Solo Shuffle'], ['BLITZ', 'Blitz'], ['ARENA_2v2', '2v2'], ['ARENA_3v3', '3v3'], ['RATED_BG', 'RBG']]; + + async function loadSessions() { + const grid = $('#session-grid'); + if (!grid) return; + try { + const data = await api.get('/api/admin/sessions'); + const today = {}; + const week = {}; + (data.today || []).forEach((x) => { today[x.bracket] = x; }); + (data.week || []).forEach((x) => { week[x.bracket] = x; }); + grid.innerHTML = SESSION_BRACKETS.map(([key, label]) => { + const t = today[key] || { wins: 0, losses: 0 }; + const w = week[key] || { wins: 0, losses: 0 }; + const total = (t.wins || 0) + (t.losses || 0); + const winrate = total > 0 ? `${Math.round((t.wins / total) * 100)}%` : '—'; + return `
+
${label} + ${t.wins}V · ${t.losses}D ${winrate} +
+
+ + +
+
7 derniers jours : ${w.wins}V · ${w.losses}D
+
`; + }).join(''); + grid.querySelectorAll('.session-win, .session-loss').forEach((b) => { + b.addEventListener('click', async () => { + await api.post('/api/admin/sessions', { bracket: b.dataset.bracket, won: Number(b.dataset.won) }); + loadSessions(); + }); + }); + $('#session-undo').hidden = !(data.last && data.last.length); + } catch { + grid.innerHTML = '

Impossible de charger le suivi de session.

'; + } + } + + async function sessionUndo() { + try { + await api.post('/api/admin/sessions/undo'); + loadSessions(); + } catch {} + } + + $('#login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + $('#login-error').hidden = true; + try { + await api.post('/api/admin/login', { password: $('#login-password').value }); + state.logged = true; + $('#login-password').value = ''; + render(); + } catch (err) { + $('#login-error').textContent = err.message; + $('#login-error').hidden = false; + } + }); + + $('#logout-btn').addEventListener('click', async () => { + await api.post('/api/admin/logout'); + state.logged = false; + render(); + }); + + // Tabs + $$('.tab-btn').forEach((btn) => { + btn.addEventListener('click', () => { + $$('.tab-btn').forEach((b) => b.classList.remove('active')); + $$('.tab-panel').forEach((p) => p.classList.remove('active')); + btn.classList.add('active'); + $(`#tab-${btn.dataset.tab}`).classList.add('active'); + }); + }); + + // ---- Characters ---- + async function loadChars() { + const chars = await api.get('/api/admin/characters'); + window.__adminChars = chars; + $('#chars-tbody').innerHTML = chars.map((c) => ` + + ${esc(c.name)}${c.note ? `
${esc(c.note)}` : ''} + + + + + ${esc(c.realm)} (${c.region}) + + + + + +
+ ${(c.titles || []).map((t) => ` + + ${t.title === 'glad' ? 'Gladiateur' : 'Légende'} + ${t.title === 'glad' ? 'Glad' : 'Leg'} + ${t.season ? `${esc(t.season)}` : ''} + + `).join('') || 'Aucun'} + + + + + +
+ + + ${esc(fmtDate(c.last_updated))} + + + + + `).join(''); + } + + $('#add-char-btn').addEventListener('click', () => { + $('#add-char-form').classList.toggle('hidden'); + }); + + $('#nc-save').addEventListener('click', async () => { + try { + await api.post('/api/admin/characters', { + region: $('#nc-region').value, + realm: $('#nc-realm').value, + name: $('#nc-name').value, + realm_slug: $('#nc-realm-slug').value || null, + class: FR_CLASS_TO_EN[$('#nc-class').value] || null, + note: $('#nc-note').value + }); + $('#nc-name').value = ''; $('#nc-realm').value = ''; $('#nc-realm-slug').value = ''; $('#nc-class').value = ''; $('#nc-note').value = ''; + $('#add-char-form').classList.add('hidden'); + await loadChars(); + await refreshAll(); + } catch (e) { + alert(e.message); + } + }); + + $('#chars-tbody').addEventListener('change', async (e) => { + const tr = e.target.closest('tr'); + if (!tr) return; + if (e.target.matches('[data-field="active"]')) { + await api.put(`/api/admin/characters/${tr.dataset.id}`, { active: e.target.checked ? 1 : 0 }); + } else if (e.target.matches('[data-field="class"]')) { + await api.put(`/api/admin/characters/${tr.dataset.id}`, { class: e.target.value || null }); + } else if (e.target.matches('[data-field="level"]')) { + const v = e.target.value.trim(); + await api.put(`/api/admin/characters/${tr.dataset.id}`, { level: v === '' ? null : Number(v) }); + } else if (e.target.matches('[data-field="check_pvp_url"]')) { + await api.put(`/api/admin/characters/${tr.dataset.id}`, { check_pvp_url: e.target.value }); + } else if (e.target.matches('[data-field="pvp_sets"]')) { + await api.put(`/api/admin/characters/${tr.dataset.id}`, { pvp_sets: e.target.value }); + } else if (e.target.matches('[data-field="pve_sets"]')) { + await api.put(`/api/admin/characters/${tr.dataset.id}`, { pve_sets: e.target.value }); + } + }); + + $('#chars-tbody').addEventListener('click', async (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const tr = btn.closest('tr'); + const id = tr.dataset.id; + if (btn.dataset.action === 'delete') { + if (!confirm('Supprimer ce personnage ?')) return; + await api.del(`/api/admin/characters/${id}`); + await loadChars(); + } else if (btn.dataset.action === 'refresh') { + btn.textContent = '…'; + await api.post('/api/admin/refresh', { id }); + btn.textContent = 'Maj'; + await loadChars(); + await loadGoals(); + await loadLogs(); + } else if (btn.dataset.action === 'pvp') { + openPvpModal(id, tr.querySelector('.char-cell-name')?.textContent || ''); + } else if (btn.dataset.action === 'add-title') { + const kind = tr.querySelector('[data-title-kind]')?.value || 'glad'; + const season = (tr.querySelector('[data-title-season]')?.value || '').trim(); + await api.post(`/api/admin/characters/${id}/titles`, { title: kind, season }); + await loadChars(); + } else if (btn.dataset.action === 'del-title') { + if (!confirm('Retirer ce titre ?')) return; + await api.del(`/api/admin/characters/${id}/titles/${btn.dataset.titleId}`); + await loadChars(); + } + }); + + // ---- Ratings PvP modal ---- + function openPvpModal(id, name) { + const chars = window.__adminChars || []; + const c = chars.find((x) => String(x.id) === String(id)); + pvpEditId = id; + $('#pvp-modal-title').textContent = `Ratings PvP — ${name}`; + let pm = {}; + try { pm = c && c.pvp_manual ? JSON.parse(c.pvp_manual || '{}') : {}; } catch {} + const classSpecs = (c && SPECS_BY_CLASS[c.class]) || []; + const cell = (spec, key, slot, val) => { + const ds = spec ? `data-spec="${spec}" ` : ''; + return ``; + }; + $('#pvp-general-body').innerHTML = PVP_GENERAL.map(([key, label]) => ` + + ${label} + ${cell(null, key, 'season', pm[key]?.season)} + ${cell(null, key, 'max', pm[key]?.max)} + ${cell(null, key, 'won', pm[key]?.won)} + ${cell(null, key, 'lost', pm[key]?.lost)} + `).join(''); + $('#pvp-specs').innerHTML = classSpecs.map((s) => { + const sp = (pm.specs && pm.specs[s.key]) || {}; + return ` +
+

${esc(s.fr)}

+ + + ${PVP_PER_SPEC.map(([key, label]) => ` + + + + + + + `).join('')} + +
BracketCette saisonMax (xp)VictoiresDéfaites
${label}${cell(s.key, key, 'season', sp[key]?.season)}${cell(s.key, key, 'max', sp[key]?.max)}${cell(s.key, key, 'won', sp[key]?.won)}${cell(s.key, key, 'lost', sp[key]?.lost)}
+
`; + }).join(''); + $('#pvp-modal-backdrop').hidden = false; + $('#pvp-save-msg').textContent = ''; + document.body.style.overflow = 'hidden'; + } + + $('#pvp-save').addEventListener('click', async () => { + const out = { specs: {} }; + const apply = (input) => { + const key = input.dataset.bracket; + const slot = input.dataset.slot; + const v = input.value.trim(); + return { key, slot, v: v === '' ? 0 : Number(v) }; + }; + for (const input of $$('#pvp-general-body input')) { + const { key, slot, v } = apply(input); + if (!out[key]) out[key] = {}; + out[key][slot] = v; + } + for (const input of $$('#pvp-specs input')) { + const spec = input.dataset.spec; + const { key, slot, v } = apply(input); + if (!out.specs[spec]) out.specs[spec] = {}; + if (!out.specs[spec][key]) out.specs[spec][key] = {}; + out.specs[spec][key][slot] = v; + } + try { + await api.put(`/api/admin/characters/${pvpEditId}`, { pvp_manual: JSON.stringify(out) }); + $('#pvp-save-msg').textContent = 'Enregistré.'; + $('#pvp-save-msg').style.color = 'var(--green)'; + setTimeout(() => closePvpModal(), 600); + } catch (err) { + $('#pvp-save-msg').textContent = err.message; + $('#pvp-save-msg').style.color = 'var(--red)'; + } + }); + + function closePvpModal() { + $('#pvp-modal-backdrop').hidden = true; + document.body.style.overflow = ''; + } + $('#pvp-modal-close').addEventListener('click', closePvpModal); + $('#pvp-modal-backdrop').addEventListener('click', (e) => { if (e.target === e.currentTarget) closePvpModal(); }); + + // ---- Goals ---- + async function loadGoals() { + const goals = await api.get('/api/admin/goals'); + $('#admin-goals').innerHTML = goals.map((g) => ` +
+
+

${esc(g.title)}

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
`).join(''); + } + + $('#admin-goals').addEventListener('change', async (e) => { + const field = e.target.dataset.gfield; + if (!field) return; + const card = e.target.closest('.goal-card'); + const key = card.dataset.key; + let value; + if (field === 'enabled') value = e.target.checked ? 1 : 0; + else if (field === 'target' || field === 'display_max') value = e.target.value === '' ? null : Number(e.target.value); + else value = e.target.value; + try { + await api.put(`/api/admin/goals/${key}`, { [field]: value }); + } catch (err) { + alert(err.message); + } + }); + + // ---- Settings ---- + function renderClassSelect(el, selected, onSave) { + const sel = new Set((selected || '').split(',').map((s) => s.trim()).filter(Boolean)); + el.innerHTML = ADMIN_CLASSES.map((c) => ` + `).join(''); + el.querySelectorAll('.class-chip').forEach((chip) => { + chip.addEventListener('click', () => { + const name = chip.dataset.name; + if (chip.classList.toggle('on')) sel.add(name); else sel.delete(name); + onSave([...sel].join(',')); + }); + }); + } + + function renderSingleClassSelect(el, selected, onSave) { + let current = (selected || '').trim(); + el.innerHTML = ADMIN_CLASSES.map((c) => ` + `).join(''); + el.querySelectorAll('.class-chip').forEach((chip) => { + chip.addEventListener('click', () => { + const name = chip.dataset.name; + current = current === name ? '' : name; + el.querySelectorAll('.class-chip').forEach((x) => x.classList.toggle('on', x.dataset.name === current)); + onSave(current); + }); + }); + } + + async function loadSettings() { + const s = await api.get('/api/admin/settings'); + const fetchEl = $('#fetch-status'); + if (fetchEl) { + const interval = s.fetch_interval_hours ? `${s.fetch_interval_hours} h` : '6 h'; + fetchEl.innerHTML = s.last_fetch_at + ? `Maj auto toutes les ${esc(interval)} · Dernière : ${esc(fmtDate(s.last_fetch_at))}` + : 'Aucune mise à jour automatique'; + } + $('#st-interval').value = s.fetch_interval_hours || ''; + $('#st-blizz-id').value = s.blizzard_client_id || ''; + $('#st-blizz-secret').value = s.blizzard_client_secret || ''; + $('#st-build').value = s.wow_tools_build || ''; + $('#st-manual-bosses').value = s.manual_mythic_bosses || ''; + $('#st-manual-ach').value = s.manual_achievements || ''; + $('#st-manual-ach-total').value = s.manual_achievements_total || ''; + $('#st-manual-glad-wins').value = s.manual_glad_wins || ''; + renderSingleClassSelect($('#st-manual-glad-class-select'), s.manual_glad_class, async (v) => { + await api.put('/api/admin/settings', { manual_glad_class: v }); + }); + $('#st-raid-name').value = s.mythic_raid_name || ''; + $('#st-raid-total').value = s.mythic_raid_total || ''; + $('#st-twitch-url').value = s.twitch_url || ''; + $('#st-youtube-url').value = s.youtube_url || ''; + $('#st-tiktok-url').value = s.tiktok_url || ''; + $('#st-glad-season').value = s.glad_title_season || ''; + $('#st-legend-season').value = s.legend_title_season || ''; + renderClassSelect($('#st-manual-class90-select'), s.manual_class90_classes, async (v) => { + await api.put('/api/admin/settings', { manual_class90_classes: v }); + }); + renderClassSelect($('#st-manual-ss2300-select'), s.manual_ss2300_classes, async (v) => { + await api.put('/api/admin/settings', { manual_ss2300_classes: v }); + }); + const gladWinsInput = $('#st-manual-glad-wins'); + gladWinsInput.dataset.savedValue = gladWinsInput.value; + gladWinsInput.addEventListener('change', async () => { + const v = gladWinsInput.value; + try { + await api.put('/api/admin/settings', { manual_glad_wins: v }); + gladWinsInput.dataset.savedValue = v; + $('#settings-error').textContent = 'Victoires gladiateur enregistrées.'; + $('#settings-error').style.color = 'var(--green)'; + } catch (e) { + gladWinsInput.value = gladWinsInput.dataset.savedValue; + $('#settings-error').textContent = e.message; + $('#settings-error').style.color = ''; + } + $('#settings-error').hidden = false; + }); + $('#st-last-fetch').textContent = s.last_fetch_at ? `Dernière mise à jour : ${fmtDate(s.last_fetch_at)}` : 'Aucune mise à jour effectuée.'; + $('#settings-error').hidden = true; + } + + async function saveSettings() { + $('#settings-error').hidden = true; + try { + await api.put('/api/admin/settings', { + fetch_interval_hours: $('#st-interval').value, + blizzard_client_id: $('#st-blizz-id').value, + blizzard_client_secret: $('#st-blizz-secret').value, + wow_tools_build: $('#st-build').value, + manual_mythic_bosses: $('#st-manual-bosses').value, + manual_achievements: $('#st-manual-ach').value, + manual_achievements_total: $('#st-manual-ach-total').value, + manual_glad_wins: $('#st-manual-glad-wins').value, + mythic_raid_name: $('#st-raid-name').value, + mythic_raid_total: $('#st-raid-total').value, + twitch_url: $('#st-twitch-url').value, + youtube_url: $('#st-youtube-url').value, + tiktok_url: $('#st-tiktok-url').value, + glad_title_season: $('#st-glad-season').value, + legend_title_season: $('#st-legend-season').value + }); + $('#settings-error').textContent = 'Paramètres enregistrés.'; + $('#settings-error').hidden = false; + $('#settings-error').style.color = 'var(--green)'; + await loadSettings(); + } catch (e) { + $('#settings-error').textContent = e.message; + $('#settings-error').hidden = false; + } + } + + $('#st-save').addEventListener('click', saveSettings); + $('#st-save-blizz').addEventListener('click', saveSettings); + + $('#st-refresh').addEventListener('click', async () => { + $('#st-refresh').textContent = 'Mise à jour en cours…'; + await api.post('/api/admin/refresh', {}); + $('#st-refresh').textContent = 'Lancer une mise à jour complète'; + await loadSettings(); + await loadLogs(); + await loadGoals(); + }); + + $('#pw-save').addEventListener('click', async () => { + try { + await api.post('/api/admin/password', { current: $('#pw-current').value, next: $('#pw-next').value }); + $('#pw-current').value = ''; $('#pw-next').value = ''; + alert('Mot de passe changé.'); + } catch (e) { + alert(e.message); + } + }); + + async function refreshAll() { + await api.post('/api/admin/refresh', {}); + await loadChars(); + await loadSettings(); + await loadLogs(); + await loadGoals(); + } + + $('#refresh-all-btn').addEventListener('click', async () => { + const btn = $('#refresh-all-btn'); + btn.disabled = true; + btn.textContent = 'Mise à jour en cours…'; + try { + await refreshAll(); + } finally { + btn.disabled = false; + btn.textContent = 'Tout actualiser'; + } + }); + + // ---- Logs ---- + async function loadLogs() { + const logs = await api.get('/api/admin/logs'); + $('#logs-tbody').innerHTML = logs.map((l) => ` + + ${esc(fmtDate(l.at))} + #${l.character_id ?? '—'} + ${esc(l.source)} + ${esc(l.status)} + ${esc(shortDetail(l.detail))} + `).join('') || 'Aucun log.'; + } + + function shortDetail(d) { + try { + const j = JSON.parse(d || '{}'); + return Object.entries(j).map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`).join(' · '); + } catch { + return d || ''; + } + } + + function fmtDate(iso) { + if (!iso) return '—'; + const d = new Date(iso); + if (isNaN(d)) return iso; + return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + } + + const SOCIAL_SVG = { + youtube: '', + tiktok: '' + }; + + function renderSocials(el, socials) { + const buttons = []; + if (socials.youtube) buttons.push(`${SOCIAL_SVG.youtube}`); + if (socials.tiktok) buttons.push(`${SOCIAL_SVG.tiktok}`); + el.innerHTML = buttons.join(''); + } + + async function updateLiveStatus(statusEl, twitchUrl) { + const channel = twitchUrl.replace(/\/+$/, '').split('/').pop() || 'wycz'; + const link = ``; + try { + const res = await fetch(`https://decapi.me/twitch/uptime/${encodeURIComponent(channel)}`, { mode: 'cors' }); + const text = (await res.text()).trim(); + const isLive = text && !/offline|hors ligne|hors-ligne|not live/i.test(text); + statusEl.innerHTML = isLive + ? `${link} Live en cours` + : `${link} Hors ligne`; + } catch { + statusEl.innerHTML = `${link} Twitch`; + } + } + + async function loadHeader() { + try { + const overview = await api.get('/api/overview'); + renderSocials($('#socials'), { youtube: (overview.youtube && overview.youtube.url) || '', tiktok: (overview.tiktok && overview.tiktok.url) || '' }); + updateLiveStatus($('#header-live'), (overview.twitch && overview.twitch.url) || 'https://twitch.tv/wycz'); + } catch { + $('#socials').innerHTML = ''; + } + } + + const sessionUndoBtn = $('#session-undo'); + if (sessionUndoBtn) sessionUndoBtn.addEventListener('click', sessionUndo); + + loadHeader(); + checkLogin(); +})(); diff --git a/backend/public/js/api.js b/backend/public/js/api.js new file mode 100644 index 0000000..a16f098 --- /dev/null +++ b/backend/public/js/api.js @@ -0,0 +1,27 @@ +window.api = { + async get(path) { + const r = await fetch(path); + if (!r.ok) { + let msg = `HTTP ${r.status}`; + try { const j = await r.json(); msg = j.error || msg; } catch {} + throw new Error(msg); + } + return r.json(); + }, + async send(method, path, body) { + const r = await fetch(path, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined + }); + if (!r.ok) { + let msg = `HTTP ${r.status}`; + try { const j = await r.json(); msg = j.error || msg; } catch {} + throw new Error(msg); + } + return r.json(); + }, + post(path, body) { return this.send('POST', path, body); }, + put(path, body) { return this.send('PUT', path, body); }, + del(path) { return this.send('DELETE', path); } +}; diff --git a/backend/public/js/app.js b/backend/public/js/app.js new file mode 100644 index 0000000..93c956d --- /dev/null +++ b/backend/public/js/app.js @@ -0,0 +1,772 @@ +(function () { + const CLASSES = [ + { name: 'Warrior', fr: 'Guerrier', logo: 'War.png', color: '#C79C6E' }, + { name: 'Paladin', fr: 'Paladin', logo: 'Pal.png', color: '#F58CBA' }, + { name: 'Hunter', fr: 'Chasseur', logo: 'Hunt.png', color: '#ABD473' }, + { name: 'Rogue', fr: 'Voleur', logo: 'Rogue.png', color: '#FFF569' }, + { name: 'Priest', fr: 'Prêtre', logo: 'Priest.png', color: '#FFFFFF' }, + { name: 'Death Knight', fr: 'Chevalier de la mort', logo: 'DK.png', color: '#C41F3B' }, + { name: 'Shaman', fr: 'Chaman', logo: 'Sham.png', color: '#0070DE' }, + { name: 'Mage', fr: 'Mage', logo: 'Mage.png', color: '#40C7EB' }, + { name: 'Warlock', fr: 'Démoniste', logo: 'Lock.png', color: '#8787ED' }, + { name: 'Monk', fr: 'Moine', logo: 'monk.png', color: '#00FF96' }, + { name: 'Druid', fr: 'Druide', logo: 'Druid.png', color: '#FF7D0A' }, + { name: 'Demon Hunter', fr: 'Chasseur de démons', logo: 'dh.png', color: '#A330C9' }, + { name: 'Evoker', fr: 'Évocateur', logo: 'evoker.png', color: '#33937F' } + ]; + + const CLASS_BY_NAME = Object.fromEntries(CLASSES.map((c) => [c.name, c])); + const classInfo = (name) => CLASS_BY_NAME[name] || { name: name || '?', fr: name || 'Inconnu', logo: null, color: '#8b94a7' }; + + const SPECS_FR = { + 'Elemental': 'Élémentaire', 'Enhancement': 'Amélioration', 'Restoration': 'Restauration', + 'Holy': 'Sacré', 'Protection': 'Protection', 'Retribution': 'Rétribution', + 'Discipline': 'Discipline', 'Shadow': 'Ombre', 'Blood': 'Sang', 'Frost': 'Givre', 'Unholy': 'Impiété', + 'Havoc': 'Dévastation', 'Vengeance': 'Vengeance', 'Devastation': 'Dévastation', 'Preservation': 'Préservation', + 'Augmentation': 'Augmentation', 'Arcane': 'Arcane', 'Fire': 'Feu', 'Assassination': 'Assassinat', + 'Outlaw': 'Hors-la-loi', 'Subtlety': 'Subtilité', 'Beast Mastery': 'Maîtrise des bêtes', + 'Marksmanship': 'Précision', 'Survival': 'Survie', 'Arms': 'Armes', 'Fury': 'Fureur', + 'Balance': 'Équilibre', 'Feral': 'Farouche', 'Guardian': 'Gardien', 'Brewmaster': 'Maître brasseur', + 'Mistweaver': 'Tisse-brume', 'Windwalker': 'Marche-vent', 'Affliction': 'Affliction', + 'Demonology': 'Démonologie', 'Destruction': 'Destruction' + }; + const specFr = (s) => (s ? (SPECS_FR[s] || s) : null); + + const SPECS_BY_CLASS = { + 'Death Knight': [ + { key: 'Blood', fr: 'Sang', icon: 'spell_deathknight_bloodpresence.jpg' }, + { key: 'Frost', fr: 'Givre', icon: 'spell_deathknight_frostpresence.jpg' }, + { key: 'Unholy', fr: 'Impie', icon: 'spell_deathknight_unholypresence.jpg' } + ], + 'Demon Hunter': [ + { key: 'Havoc', fr: 'Dévastation', icon: 'ability_demonhunter_specdps.jpg' }, + { key: 'Vengeance', fr: 'Vengeance', icon: 'ability_demonhunter_spectank.jpg' }, + { key: 'Devoration', fr: 'Dévoration', icon: 'classicon_demonhunter_void.jpg' } + ], + 'Druid': [ + { key: 'Balance', fr: 'Équilibre', icon: 'spell_nature_starfall.jpg' }, + { key: 'Feral', fr: 'Farouche', icon: 'ability_druid_catform.jpg' }, + { key: 'Guardian', fr: 'Gardien', icon: 'ability_racial_bearform.jpg' }, + { key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_healingtouch.jpg' } + ], + 'Evoker': [ + { key: 'Devastation', fr: 'Dévastation', icon: 'classicon_evoker_devastation.jpg' }, + { key: 'Preservation', fr: 'Préservation', icon: 'classicon_evoker_preservation.jpg' }, + { key: 'Augmentation', fr: 'Augmentation', icon: 'classicon_evoker_augmentation.jpg' } + ], + 'Hunter': [ + { key: 'Beast Mastery', fr: 'Maîtrise des bêtes', icon: 'ability_hunter_bestialdiscipline.jpg' }, + { key: 'Marksmanship', fr: 'Précision', icon: 'ability_hunter_focusedaim.jpg' }, + { key: 'Survival', fr: 'Survie', icon: 'ability_hunter_camouflage.jpg' } + ], + 'Mage': [ + { key: 'Arcane', fr: 'Arcanes', icon: 'spell_holy_magicalsentry.jpg' }, + { key: 'Fire', fr: 'Feu', icon: 'spell_fire_firebolt02.jpg' }, + { key: 'Frost', fr: 'Givre', icon: 'spell_frost_frostbolt02.jpg' } + ], + 'Monk': [ + { key: 'Brewmaster', fr: 'Maître brasseur', icon: 'spell_monk_brewmaster_spec.jpg' }, + { key: 'Mistweaver', fr: 'Tisse-brume', icon: 'spell_monk_mistweaver_spec.jpg' }, + { key: 'Windwalker', fr: 'Marche-vent', icon: 'spell_monk_windwalker_spec.jpg' } + ], + 'Paladin': [ + { key: 'Holy', fr: 'Sacré', icon: 'spell_holy_holybolt.jpg' }, + { key: 'Protection', fr: 'Protection', icon: 'ability_paladin_shieldofthetemplar.jpg' }, + { key: 'Retribution', fr: 'Vindicte', icon: 'spell_holy_auraoflight.jpg' } + ], + 'Priest': [ + { key: 'Discipline', fr: 'Discipline', icon: 'spell_holy_powerwordshield.jpg' }, + { key: 'Holy', fr: 'Sacré', icon: 'spell_holy_guardianspirit.jpg' }, + { key: 'Shadow', fr: 'Ombre', icon: 'spell_shadow_shadowwordpain.jpg' } + ], + 'Rogue': [ + { key: 'Assassination', fr: 'Assassinat', icon: 'ability_rogue_deadlybrew.jpg' }, + { key: 'Outlaw', fr: 'Hors-la-loi', icon: 'ability_rogue_waylay.jpg' }, + { key: 'Subtlety', fr: 'Finesse', icon: 'ability_stealth.jpg' } + ], + 'Shaman': [ + { key: 'Elemental', fr: 'Élémentaire', icon: 'spell_nature_lightning.jpg' }, + { key: 'Enhancement', fr: 'Amélioration', icon: 'spell_shaman_improvedstormstrike.jpg' }, + { key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_magicimmunity.jpg' } + ], + 'Warlock': [ + { key: 'Affliction', fr: 'Affliction', icon: 'spell_shadow_deathcoil.jpg' }, + { key: 'Demonology', fr: 'Démonologie', icon: 'spell_shadow_metamorphosis.jpg' }, + { key: 'Destruction', fr: 'Destruction', icon: 'spell_shadow_rainoffire.jpg' } + ], + 'Warrior': [ + { key: 'Arms', fr: 'Armes', icon: 'ability_warrior_innerrage.jpg' }, + { key: 'Fury', fr: 'Fureur', icon: 'ability_warrior_savageblow.jpg' }, + { key: 'Protection', fr: 'Protection', icon: 'ability_warrior_defensivestance.jpg' } + ] + }; + + const $ = (sel) => document.querySelector(sel); + const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + + let classGroups = new Map(); + let classTitles = { glad: [], legend: [] }; + + function fmt(n) { + if (n === null || n === undefined) return '—'; + return n.toLocaleString('fr-FR'); + } + + function fmtSet(v) { + if (v === null || v === undefined || v === '' || v === 0) return '—'; + return String(v); + } + + function pct(n, d) { + return d > 0 ? Math.max(0, Math.min(100, Math.round((n / d) * 100))) : 0; + } + + function charTopRating(c) { + let best = 0; + const pm = c.pvp_manual || {}; + for (const k of ['ARENA_2v2', 'ARENA_3v3', 'RATED_BG']) { + const m = pm[k] || {}; + const v = Math.max(m.max || 0, m.season || 0); + if (v > best) best = v; + } + const specs = pm.specs || {}; + for (const k of Object.keys(specs)) { + for (const br of ['SOLO_SHUFFLE', 'BLITZ']) { + const m = specs[k][br] || {}; + const v = Math.max(m.max || 0, m.season || 0); + if (v > best) best = v; + } + } + if (best) return best; + const auto = (c.data && (c.data.blizzard || c.data.raiderio || {}).pvp) || {}; + return Math.max(0, ...Object.values(auto).map((x) => (x && typeof x.rating === 'number' ? x.rating : 0))); + } + + function armoryImage(thumbnail) { + if (!thumbnail) return null; + return thumbnail.replace(/-avatar\.jpg/i, '-inset.jpg'); + } + + function overviewBadges(classes) { + const covered = new Set(classes || []); + const items = CLASSES.map((cls) => { + const done = covered.has(cls.name); + const img = cls.logo + ? `${esc(cls.fr)}` + : `${esc(cls.fr[0])}`; + return `${img}${done ? '✓' : ''}`; + }).join(''); + return `
${items}
`; + } + + const BEST_ORDER = [['ARENA_2v2', '2v2'], ['ARENA_3v3', '3v3'], ['SOLO_SHUFFLE', 'Solo Shuffle'], ['BLITZ', 'Blitz'], ['RATED_BG', 'RBG']]; + + const BEST_COLOR = { + ARENA_2v2: '#5aa9ff', + ARENA_3v3: '#ff5c5c', + SOLO_SHUFFLE: '#37e0ff', + BLITZ: '#ffb347', + RATED_BG: '#7ed957' + }; + + function bestsTable(bests) { + const rows = BEST_ORDER.map(([key, label]) => { + const b = bests[key] || null; + if (!b) { + return `
+
${label}
+
—
+
`; + } + const info = classInfo(b.class); + const spec = b.spec ? specFr(b.spec) : null; + const specEntry = (SPECS_BY_CLASS[info.name] || []).find((s) => s.key === b.spec); + const avatar = specEntry && specEntry.icon + ? `` + : (info.logo ? `` : ''); + const tier = tierFor(b.rating); + return `
+
+ ${label} + ${tier ? `${tier.name}` : ''} +
+
+ ${avatar} +
${esc(info.fr)}${spec ? ` · ${esc(spec)}` : ''}
+
${esc(b.name || '')}
+
+
${fmt(b.rating)}
+
`; + }).join(''); + return `
+

Meilleure classe & spé par bracket

+
${rows}
+
`; + } + + function overviewCard(g) { + const manualBadge = g.manual && g.key !== 'achievements' ? 'manuel' : ''; + const done = !!g.achieved; + const doneBadge = done ? '✓ Atteint' : ''; + let value; + let sub; + let progress = 0; + let badges = ''; + if (g.key === 'class90' || g.key === 'ss2300') { + value = `${fmt(g.covered)} / ${g.total} classes`; + sub = g.total > 0 ? `${pct(g.covered, g.total)}%` : '—'; + progress = g.total > 0 ? g.covered / g.total : 0; + badges = overviewBadges(g.classes); + } else if (g.key === 'gladiator_s2') { + const w = g.wins || 0; + const t = g.total || 50; + value = g.obtained ? 'Obtenu' : `${fmt(w)} / ${fmt(t)} victoires`; + sub = ''; + progress = t > 0 ? Math.min(1, w / t) : 0; + badges = overviewBadges(g.classes || []); + } else if (g.key === 'achievements') { + const pc = g.total > 0 ? pct(g.points, g.total) : 0; + value = `${fmt(g.points)} / ${fmt(g.total)}`; + sub = `${pc}% · Points de hauts faits`; + progress = g.total > 0 ? Math.min(1, g.points / g.total) : 0; + } + return ` +
+
+

${esc(g.title)}

+ ${manualBadge} + ${doneBadge} +
+
${value}
+ ${sub ? `
${esc(sub)}
` : ''} +
+ ${badges} +
`; + } + + const TIERS = [ + { min: 2400, name: 'Elite', color: '#ff8000' }, + { min: 2100, name: 'Duelist', color: '#a335ee' }, + { min: 1800, name: 'Rival', color: '#0070dd' }, + { min: 1400, name: 'Challenger', color: '#1eff00' }, + { min: 0, name: 'Combatant', color: '#9d9d9d' } + ]; + const tierFor = (rating) => { + if (!rating || rating <= 0) return null; + return TIERS.find((t) => rating >= t.min) || null; + }; + + const brkBlock = (label, color, b) => { + const current = b.season || null; + const max = b.max || null; + const tier = tierFor(current); + const total = (b.won || 0) + (b.lost || 0); + const wr = total > 0 ? Math.round((b.won / total) * 100) : null; + return ` +
+
+ ${label} + ${tier ? `${tier.name}` : 'Non classé'} + ${b.isRecord ? '★ Record' : ''} +
+
+ ${current ? fmt(current) : '—'} + ${max != null ? `/${fmt(max)}` : ''} +
+
${current != null ? `Côte actuelle · ` : ''}${max != null ? `Max XP ${fmt(max)}` : (current != null ? 'Aucun max enregistré' : 'Aucune côte')}
+
+
${fmt(b.won)}V · ${fmt(b.lost)}D${wr != null ? ` ${wr}%` : ''}
+
${total ? `${fmt(total)} games` : 'Aucune game cette saison'}
+
`; + }; + + function bracketValues(c) { + const pm = c.pvp_manual || {}; + const auto = c.pvp || {}; + const autoOf = (key) => (auto[key] && typeof auto[key].rating === 'number' ? auto[key] : null); + const resolve = (key, m) => { + const a = autoOf(key) || {}; + const season = m.season || (a.rating || null); + const max = m.max || (c.pvp_best && c.pvp_best[key]) || null; + return { + season, + max, + won: m.won || a.won || 0, + lost: m.lost || a.lost || 0, + weekly_won: a.weekly_won || 0, + weekly_lost: a.weekly_lost || 0, + tier: a.tier || null, + isRecord: !!(c.pvp_records && c.pvp_records[key]) + }; + }; + return { + generalVal: (key) => resolve(key, pm[key] || {}), + specVal: (spec, key) => { + const s = (pm.specs && pm.specs[spec]) || {}; + return resolve(key, s[key] || {}); + } + }; + } + + function bracketsGrid(c, spec) { + const { generalVal, specVal } = bracketValues(c); + const PANELS = [ + ['2v2', '#5aa9ff', generalVal('ARENA_2v2')], + ['3v3', '#ff5c5c', generalVal('ARENA_3v3')], + ['Solo', '#37e0ff', spec ? specVal(spec, 'SOLO_SHUFFLE') : null], + ['Blitz', '#ffb347', spec ? specVal(spec, 'BLITZ') : null], + ['RBG', '#7ed957', generalVal('RATED_BG')] + ]; + return `
${PANELS.map(([label, color, b]) => brkBlock(label, color, b)).join('')}
`; + } + + let modalChars = []; + let modalSpecByChar = {}; + const fullCache = {}; + function getFull(id) { + if (!fullCache[id]) fullCache[id] = api.get(`/api/characters/${id}`); + return fullCache[id]; + } + + function charRow(c) { + const info = classInfo(c.class); + const frozen = (c.media && (c.media.main || c.media.inset || c.media.avatar)) || renderImage(c.thumbnail_url) || c.thumbnail_url || null; + const ilvl = c.ilvl || null; + const specs = SPECS_BY_CLASS[info.name] || []; + const sel = modalSpecByChar[c.id] || null; + const specPanels = specs.map((s) => ` + `).join(''); + const metaBits = []; + if (c.realm) metaBits.push(esc(c.realm)); + if (c.race) metaBits.push(esc(c.race)); + const faction = c.faction === 'ALLIANCE' || c.faction === 'Alliance' ? 'Alliance' : c.faction === 'HORDE' || c.faction === 'Horde' ? 'Horde' : null; + if (faction) metaBits.push(faction); + const metaText = metaBits.filter(Boolean).join(' · '); + let detail = ''; + if (sel) { + detail = ` +
+ ${bracketsGrid(c, sel)} + + +
`; + } + return ` +
+
${specPanels}
+ +
+
${c.check_pvp_url ? `${esc(c.name)}` : esc(c.name)}
+
${metaText}${ilvl != null ? `ILVL ${fmt(ilvl)}` : ''}
+
+
${classTitleEntries(info, classTitles)}
+ ${detail} +
`; + } + + function titleTags(cls, titles) { + const parts = []; + if ((titles.glad || []).includes(cls.name)) parts.push('Gladiateur'); + if ((titles.legend || []).includes(cls.name)) parts.push('Légende'); + if (!parts.length) return ''; + return `${parts.join('')}`; + } + + function classTitleEntries(cls, titles) { + const items = []; + for (const e of (titles.glad_entries || [])) { + if (e.class === cls.name) items.push(`GladiateurGladiateur${e.season ? esc(e.season) : ''}`); + } + for (const e of (titles.legend_entries || [])) { + if (e.class === cls.name) items.push(`LégendeLégende${e.season ? esc(e.season) : ''}`); + } + if (!items.length) { + if ((titles.glad || []).includes(cls.name)) items.push(`GladiateurGladiateur${titles.glad_season ? esc(titles.glad_season) : ''}`); + if ((titles.legend || []).includes(cls.name)) items.push(`LégendeLégende${titles.legend_season ? esc(titles.legend_season) : ''}`); + } + return items.join(''); + } + + function renderModalChars() { + if (!modalChars.length) { + $('#class-modal-chars').innerHTML = '
Aucun personnage pour cette classe
'; + return; + } + $('#class-modal-chars').innerHTML = modalChars.map((c) => charRow(c)).join(''); + for (const c of modalChars) { + const sel = modalSpecByChar[c.id]; + if (!sel) continue; + const gearEl = document.querySelector(`[data-gear="${c.id}"]`); + const talentsEl = document.querySelector(`[data-talents="${c.id}"]`); + if (gearEl) { + getFull(c.id).then((full) => { + if (gearEl) gearEl.innerHTML = gearHTML(full.gear); + }).catch(() => { + if (gearEl) gearEl.innerHTML = '
Équipement indisponible
'; + }); + } + if (talentsEl) { + talentsEl.innerHTML = '
Chargement…
'; + api.get(`/api/characters/${c.id}/talents?spec=${encodeURIComponent(sel)}`).then((t) => { + if (talentsEl) talentsEl.innerHTML = talentTreeHTML(t); + }).catch(() => { + if (talentsEl) talentsEl.innerHTML = '
Détails indisponibles
'; + }); + } + } + } + + function openClassModal(cls, chars, titles) { + modalChars = chars; + modalSpecByChar = {}; + $('#class-modal-logo').src = cls.logo ? `/images/classes/${cls.logo}` : ''; + $('#class-modal-logo').style.display = cls.logo ? '' : 'none'; + renderModalChars(); + $('#class-modal-backdrop').hidden = false; + document.body.style.overflow = 'hidden'; + } + + $('#class-modal-chars').addEventListener('click', (e) => { + const panel = e.target.closest('.wm-spec-panel'); + if (!panel) return; + const id = panel.dataset.char; + const spec = panel.dataset.spec; + modalSpecByChar[id] = modalSpecByChar[id] === spec ? null : spec; + renderModalChars(); + const selPanel = $('#class-modal-chars').querySelector(`.wm-spec-panel.on[data-char="${CSS.escape(id)}"]`); + if (selPanel) { + selPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + const detail = selPanel.closest('.class-char').querySelector('.wm-detail'); + if (detail) detail.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + + function closeClassModal() { + $('#class-modal-backdrop').hidden = true; + document.body.style.overflow = ''; + } + + async function load() { + const statusEl = $('#header-status'); + const overviewEl = $('#overview-grid'); + + try { + const [overview, chars] = await Promise.all([api.get('/api/overview'), api.get('/api/characters')]); + const cards = [ + { key: 'class90', title: 'Classes niveau 90', covered: overview.class90.covered, total: overview.class90.total, manual: overview.class90.manual, classes: overview.class90.classes, achieved: overview.class90.total > 0 && overview.class90.covered >= overview.class90.total }, + { key: 'ss2300', title: 'Toutes les classes à 2300 en soloshuffle — Saison 2', covered: overview.ss2300.covered, total: overview.ss2300.total, manual: overview.ss2300.manual, classes: overview.ss2300.classes, achieved: overview.ss2300.total > 0 && overview.ss2300.covered >= overview.ss2300.total }, + { key: 'gladiator_s2', title: 'Gladiateur S2 Midnight', obtained: overview.gladiator_s2.obtained, wins: overview.gladiator_s2.wins, total: overview.gladiator_s2.total, rating: overview.gladiator_s2.rating, class: overview.gladiator_s2.class, classes: (overview.titles && overview.titles.glad_s2) || [], achieved: !!overview.gladiator_s2.obtained } + ]; + overviewEl.innerHTML = cards.map(overviewCard).join('') + bestsTable(overview.bests || {}); + + const grouped = new Map(); + for (const c of chars) { + const key = classInfo(c.class).name; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(c); + } + for (const list of grouped.values()) { + list.sort((a, b) => charTopRating(b) - charTopRating(a)); + } + classGroups = grouped; + classTitles = overview.titles || { glad: [], legend: [] }; + + const headerClasses = $('#header-classes'); + if (headerClasses) { + const class90 = new Set(overview.class90.classes || []); + headerClasses.innerHTML = CLASSES.map((cls) => ` + `).join(''); + } + + const twitchUrl = overview.twitch.url || 'https://twitch.tv/wycz'; + updateLiveStatus(statusEl, twitchUrl); + renderSocials($('#socials'), { + youtube: (overview.youtube && overview.youtube.url) || '', + tiktok: (overview.tiktok && overview.tiktok.url) || '' + }); + } catch (e) { + overviewEl.innerHTML = `
Erreur de chargement : ${esc(e.message)}
`; + } + } + + async function updateLiveStatus(statusEl, twitchUrl) { + const channel = twitchUrl.replace(/\/+$/, '').split('/').pop() || 'wycz'; + const safeChannel = encodeURIComponent(channel); + const link = ``; + const twitchLogo = `${SOCIAL_SVG.twitch}`; + try { + const res = await fetch(`https://decapi.me/twitch/uptime/${safeChannel}`, { mode: 'cors' }); + const text = (await res.text()).trim(); + const isLive = text && !/offline|hors ligne|hors-ligne|not live/i.test(text); + statusEl.innerHTML = `${link}${twitchLogo}`; + } catch { + statusEl.innerHTML = `${link}${twitchLogo}`; + } + } + + const SOCIAL_SVG = { + twitch: '', + youtube: '', + tiktok: '' + }; + + function renderSocials(el, socials) { + const buttons = []; + if (socials.youtube) { + buttons.push(`${SOCIAL_SVG.youtube}`); + } + if (socials.tiktok) { + buttons.push(`${SOCIAL_SVG.tiktok}`); + } + el.innerHTML = buttons.join(''); + } + + const backdrop = $('#modal-backdrop'); + const modalArt = $('#modal-art'); + const modalError = $('#modal-error'); + + function closeModal() { + backdrop.hidden = true; + document.body.style.overflow = ''; + } + + document.addEventListener('click', (e) => { + const copyBtn = e.target.closest('[data-copy]'); + if (copyBtn) { + navigator.clipboard.writeText(copyBtn.dataset.copy || '').then(() => { + copyBtn.textContent = 'Copié !'; + setTimeout(() => { copyBtn.textContent = 'Copier le build'; }, 1600); + }).catch(() => {}); + return; + } + const classIcon = e.target.closest('.class-icon'); + if (classIcon) { + const name = classIcon.dataset.class; + openClassModal(classInfo(name), classGroups.get(name) || [], classTitles); + return; + } + if (e.target === $('#class-modal-backdrop')) { + closeClassModal(); + return; + } + const openBtn = e.target.closest('[data-open3d]'); + if (openBtn) { + if (e.target.closest('a')) return; + const id = openBtn.dataset.open3d; + modalError.hidden = true; + modalArt.style.display = 'flex'; + modalArt.innerHTML = '
Chargement…
'; + $('#modal-stats').innerHTML = ''; + $('#modal-title').textContent = '…'; + $('#modal-subtitle').textContent = ''; + backdrop.hidden = false; + document.body.style.overflow = 'hidden'; + api.get(`/api/characters/${id}`).then((full) => { + $('#modal-title').textContent = full.name; + const sub = []; + if (full.spec) sub.push(specFr(full.spec)); + if (full.race) sub.push(full.race); + if (full.faction) sub.push(full.faction); + sub.push(full.realm); + $('#modal-subtitle').textContent = sub.filter(Boolean).join(' · '); + const thumb = (full.media && full.media.main) || full.media?.avatar || full.thumbnail_url || null; + const img = full.media && full.media.main ? thumb : renderImage(thumb); + if (img) { + modalArt.innerHTML = ``; + } else { + modalArt.innerHTML = '
Image de l\'armurerie non disponible
'; + } + renderStats(full); + $('#modal-gear').innerHTML = gearHTML(full.gear); + if (full.spec) { + $('#modal-talents').innerHTML = '
Chargement…
'; + api.get(`/api/characters/${id}/talents?spec=${encodeURIComponent(full.spec)}`).then((t) => { + $('#modal-talents').innerHTML = talentTreeHTML(t); + }).catch(() => { + $('#modal-talents').innerHTML = talentHTML(full); + }); + } else { + $('#modal-talents').innerHTML = talentHTML(full); + } + }).catch((err) => { + modalArt.innerHTML = ''; + modalError.textContent = `Impossible d'afficher les détails : ${err.message}`; + modalError.hidden = false; + }); + return; + } + if (e.target === backdrop) closeModal(); + }); + + function renderImage(thumbnail) { + if (!thumbnail) return null; + return thumbnail.split('?')[0].replace(/-avatar\.jpg/i, '-main-raw.png'); + } + + const GEAR_ORDER = [ + ['head', 'Tête'], ['neck', 'Cou'], ['shoulder', 'Épaules'], ['back', 'Dos'], ['chest', 'Torse'], + ['wrist', 'Poignets'], ['hands', 'Mains'], ['waist', 'Taille'], ['legs', 'Jambes'], ['feet', 'Pieds'], + ['finger1', 'Doigt 1'], ['finger2', 'Doigt 2'], ['trinket1', 'Bijou 1'], ['trinket2', 'Bijou 2'], + ['mainhand', 'Main droite'], ['offhand', 'Main gauche'] + ]; + const QUALITY_COLOR = ['#9d9d9d', '#ffffff', '#1eff00', '#0070dd', '#a335ee', '#ff8000', '#e6cc80']; + + function gearHTML(gear) { + if (!gear || !gear.length) return '
Aucun équipement disponible (données Raider.io)
'; + const bySlot = {}; + for (const it of gear) bySlot[it.slot_name] = it; + const items = GEAR_ORDER.map(([slot, label]) => bySlot[slot] || { slot, slot_name: slot, label, empty: true }); + const main = (it) => it.empty + ? ` + ${esc((it.label || it.slot_name || '?')[0])} + ${esc(it.label || it.slot_name)}Vide + ` + : ` + ${it.icon + ? `` + : `${esc((it.name || '?')[0])}`} + + ${it.item_id + ? `${esc(it.name)}` + : `${esc(it.name)}`} + ${it.item_level ? `ILVL ${it.item_level}` : ''} + + `; + return items.map(main).join(''); + } + + const TALENT_BG = { + 'Warrior': { Arms: 'warrior-arms', Fury: 'warrior-fury', Protection: 'warrior-protection' }, + 'Paladin': { Holy: 'paladin-holy', Protection: 'paladin-protection', Retribution: 'paladin-retribution' }, + 'Hunter': { 'Beast Mastery': 'hunter-beastmastery', Marksmanship: 'hunter-marksmanship', Survival: 'hunter-survival' }, + 'Rogue': { Assassination: 'rogue-assassination', Outlaw: 'rogue-outlaw', Subtlety: 'rogue-subtlety' }, + 'Priest': { Discipline: 'priest-discipline', Holy: 'priest-holy', Shadow: 'priest-shadow' }, + 'Death Knight': { Blood: 'death-knight-blood', Frost: 'death-knight-frost', Unholy: 'death-knight-unholy' }, + 'Shaman': { Elemental: 'shaman-elemental', Enhancement: 'shaman-enhancement', Restoration: 'shaman-restoration' }, + 'Mage': { Arcane: 'mage-arcane', Fire: 'mage-fire', Frost: 'mage-frost' }, + 'Warlock': { Affliction: 'warlock-affliction', Demonology: 'warlock-demonology', Destruction: 'warlock-destruction' }, + 'Monk': { Brewmaster: 'monk-brewmaster', Mistweaver: 'monk-mistweaver', Windwalker: 'monk-windwalker' }, + 'Druid': { Balance: 'druid-balance', Feral: 'druid-feral', Guardian: 'druid-guardian', Restoration: 'druid-restoration' }, + 'Demon Hunter': { Havoc: 'demon-hunter-havoc', Vengeance: 'demon-hunter-vengeance' }, + 'Evoker': { Devastation: 'evoker-devastation', Preservation: 'evoker-preservation', Augmentation: 'evoker-augmentation' } + }; + + function talentHTML(full) { + const cls = full.class || ''; + const spec = full.spec || ''; + const bg = TALENT_BG[cls] && TALENT_BG[cls][spec] ? `talentbg-${TALENT_BG[cls][spec]}.jpg` : null; + return `
+ ${bg ? `` : ''} +
+ Arbre de talents — ${esc(specFr(spec) || spec || '?')} + Arbre indisponible pour le moment. +
+
`; + } + + function talentTreeHTML(data) { + if (!data || !data.trees) return '
Arbre de talents indisponible
'; + const selByTalent = { class: {}, spec: {} }; + const selected = data.selected || { class: [], spec: [] }; + for (const k of ['class', 'spec']) { + for (const t of (selected[k] || [])) { + const arr = selByTalent[k][t.id] || (selByTalent[k][t.id] = []); + arr.push(t.rank || 1); + } + } + const CELL = 58, PAD = 8; + const renderTree = (kind, title) => { + const tree = data.trees[kind]; + if (!tree || !tree.nodes || !tree.nodes.length) return ''; + const w = (tree.width ?? 12) + 1; + const h = (tree.height ?? 10) + 1; + const nodes = tree.nodes.map((n) => { + const ranks = selByTalent[kind][n.id] || selByTalent[kind][n.talent_id]; + const on = ranks && ranks.length; + const letter = esc((n.talent_name || '?')[0]); + const badge = on ? `${ranks[0]}${n.max_rank > 1 ? '/' + n.max_rank : ''}` : ''; + const tip = n.spell_id + ? `data-wowhead="spell=${n.spell_id}&domain=fr" title="${esc(n.talent_name || n.spell_name || '')}"` + : (n.talent_name ? `title="${esc(n.talent_name)}"` : ''); + return ` + ${n.icon ? `` : ''} + ${letter} + ${badge} + `; + }).join(''); + return `
+
${title}${tree.name ? `${esc(tree.name)}` : ''}
+
${nodes}
+
`; + }; + const copyBtn = data.loadout_text + ? `` + : ''; + return `
+
Talents — ${esc(specFr(data.spec) || data.spec)}${copyBtn}
+ ${renderTree('class', 'Arbre de classe')} + ${renderTree('spec', 'Arbre de spécialisation')} +
`; + } + + function renderStats(card) { + const stats = []; + stats.push(['ILVL', fmt(card.ilvl || null)]); + stats.push(['Hauts faits', fmt(card.achievement_points || 0)]); + stats.push(['Sets PvP', fmtSet(card.pvp_sets)]); + stats.push(['Sets PvE', fmtSet(card.pve_sets)]); + for (const [label, key] of [['Blitz', 'BLITZ'], ['Solo Shuffle', 'SOLO_SHUFFLE'], ['2v2', 'ARENA_2v2'], ['3v3', 'ARENA_3v3'], ['RBG', 'RATED_BG']]) { + const rating = card.pvp && card.pvp[key] ? card.pvp[key].rating : null; + const best = card.pvp_best && card.pvp_best[key] || null; + const v = rating != null ? `${fmt(rating)}${best != null ? `/ ${fmt(best)}` : ''}` : '—'; + stats.push([label, v]); + } + $('#modal-stats').innerHTML = stats + .map(([l, v]) => `${l} ${v}`) + .join(''); + } + + $('#modal-close').addEventListener('click', closeModal); + $('#class-modal-close').addEventListener('click', closeClassModal); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + if (!backdrop.hidden) closeModal(); + if (!$('#class-modal-backdrop').hidden) closeClassModal(); + } + }); + + function initStarfield() { + const field = $('#starfield'); + if (!field) return; + const count = Math.max(26, Math.min(70, Math.round((window.innerWidth * window.innerHeight) / 34000))); + const color = '#f5a623'; + let html = ''; + for (let i = 0; i < count; i++) { + const size = (Math.random() * 7 + 3.5).toFixed(2); + const left = (Math.random() * 100).toFixed(2); + const fall = (Math.random() * 16 + 12).toFixed(2); + const sway = (Math.random() * 4 + 3.5).toFixed(2); + const delay = (-Math.random() * 30).toFixed(2); + const peak = (Math.random() * 0.25 + 0.12).toFixed(2); + html += ``; + } + field.innerHTML = html; + } + + initStarfield(); + load(); +})(); diff --git a/backend/public/js/model3d.js b/backend/public/js/model3d.js new file mode 100644 index 0000000..abbe6d3 --- /dev/null +++ b/backend/public/js/model3d.js @@ -0,0 +1,100 @@ +window.model3d = (function () { + const CONTENT_PATH = '/mv/'; + let libsLoaded = false; + + function loadScript(src) { + return new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = src; + s.onload = resolve; + s.onerror = () => reject(new Error('Impossible de charger ' + src)); + document.head.appendChild(s); + }); + } + + async function ensureLibs() { + if (libsLoaded && window.jQuery && window.ZamModelViewer) return; + if (!window.jQuery) await loadScript('https://code.jquery.com/jquery-3.7.1.min.js'); + if (!window.ZamModelViewer) await loadScript(CONTENT_PATH + 'viewer/viewer.min.js'); + if (!window.jQuery) throw new Error('jQuery indisponible'); + if (!window.ZamModelViewer) throw new Error('Moteur 3D indisponible'); + if (!window.WH) window.WH = {}; + window.WH.defaultAnimation = 'Stand'; + if (!window.WH.debug) window.WH.debug = function () {}; + if (!window.WH.WebP) { + window.WH.WebP = { + getImageExtension: function () { return '.webp'; } + }; + } + libsLoaded = true; + } + + async function loadCharacterOptions(race, gender) { + const id = race * 2 - 1 + gender; + const res = await fetch(`${CONTENT_PATH}meta/charactercustomization/${id}.json`); + if (!res.ok) return { Options: [] }; + return res.json(); + } + + function buildOptions(model, fullOptions) { + const map = { + 'Face': model.appearance && model.appearance.face, + 'Skin Color': model.appearance && model.appearance.skin_color, + 'Hair Style': model.appearance && model.appearance.hair_variation, + 'Hair Color': model.appearance && model.appearance.hair_color, + 'Facial Hair': model.appearance && model.appearance.facial_hair + }; + const hasAppearance = Object.values(map).some((v) => v !== null && v !== undefined && v !== false); + const options = []; + if (!hasAppearance) return options; + for (const part of fullOptions.Options || []) { + const val = map[part.Name]; + if (val === null || val === undefined || val === false) continue; + const choice = part.Choices && (part.Choices[val] || part.Choices.find((c) => String(c.Id) === String(val))); + if (choice) options.push({ optionId: part.Id, choiceId: choice.Id }); + } + return options; + } + + async function render(container, model) { + await ensureLibs(); + const $c = jQuery(container); + $c.empty(); + const viewerOptions = { + type: 2, + contentPath: CONTENT_PATH, + container: $c, + aspect: 1, + models: { id: model.race * 2 - 1 + model.gender, type: 16 }, + items: model.items || [], + hd: true + }; + + let viewer = null; + try { + const options = buildOptions(model, await loadCharacterOptions(model.race, model.gender)); + if (options.length) viewerOptions.charCustomization = { options }; + viewer = await new window.ZamModelViewer(viewerOptions); + } catch (e) { + viewer = null; + } + + if (!viewer) { + const options = buildOptions(model, { Options: [] }); + viewer = await new window.ZamModelViewer(Object.assign({}, viewerOptions, { charCustomization: { options } })); + } + + try { viewer.renderer.distance = 2.1; } catch (e) {} + try { + const actor = viewer.renderer.actors && viewer.renderer.actors[0]; + if (actor && actor.setAnimation) actor.setAnimation('Stand'); + } catch (e) {} + return viewer; + } + + function dispose(viewer) { + try { viewer && viewer.dispose && viewer.dispose(); } catch (e) {} + } + + return { render, dispose, ensureLibs }; +})(); diff --git a/backend/public/manifest.webmanifest b/backend/public/manifest.webmanifest new file mode 100644 index 0000000..c76d5ee --- /dev/null +++ b/backend/public/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "Wycz - World of Warcraft", + "short_name": "Wycz", + "description": "Objectifs de saison, classes et cotes PvP de Wycz.", + "lang": "fr", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0c0e14", + "theme_color": "#0c0e14", + "icons": [ + { "src": "/images/icons/icon-96.png", "sizes": "96x96", "type": "image/png" }, + { "src": "/images/icons/icon-180.png", "sizes": "180x180", "type": "image/png" }, + { "src": "/images/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/images/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/backend/public/sw.js b/backend/public/sw.js new file mode 100644 index 0000000..45e5175 --- /dev/null +++ b/backend/public/sw.js @@ -0,0 +1,68 @@ +const CACHE = 'wycz-v1'; +const CORE = [ + '/', + '/css/style.css', + '/js/app.js', + '/manifest.webmanifest', + '/images/logo.png', + '/images/icons/icon-96.png', + '/images/icons/icon-192.png', + '/images/icons/icon-512.png' +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE) + .then((cache) => cache.addAll(CORE)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const req = event.request; + if (req.method !== 'GET') return; + + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + + if (url.pathname.startsWith('/api/')) { + event.respondWith(fetch(req)); + return; + } + + if (req.mode === 'navigate') { + event.respondWith( + fetch(req) + .then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put('/', copy)); + return res; + }) + .catch(() => caches.match('/')) + ); + return; + } + + event.respondWith( + caches.match(req).then((cached) => { + const network = fetch(req) + .then((res) => { + if (res.ok) { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)); + } + return res; + }) + .catch(() => cached); + return cached || network; + }) + ); +}); diff --git a/backend/src/auth.js b/backend/src/auth.js new file mode 100644 index 0000000..8109387 --- /dev/null +++ b/backend/src/auth.js @@ -0,0 +1,58 @@ +import crypto from 'node:crypto'; +import { config } from './config.js'; +import { getSetting } from './db.js'; + +export function hashPassword(password) { + const salt = crypto.randomBytes(16).toString('hex'); + const hash = crypto.scryptSync(password, salt, 64).toString('hex'); + return `${salt}:${hash}`; +} + +export function verifyPassword(password, stored) { + if (!stored) return false; + const [salt, hash] = stored.split(':'); + if (!salt || !hash) return false; + const test = crypto.scryptSync(password, salt, 64).toString('hex'); + return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(test, 'hex')); +} + +export function sign(payload) { + const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const sig = crypto.createHmac('sha256', config.sessionSecret).update(body).digest('base64url'); + return `${body}.${sig}`; +} + +export function verify(token) { + if (!token) return null; + const [body, sig] = token.split('.'); + if (!body || !sig) return null; + const expected = crypto.createHmac('sha256', config.sessionSecret).update(body).digest('base64url'); + let ok = false; + try { + ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); + } catch { + return null; + } + if (!ok) return null; + try { + const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')); + if (payload.exp && payload.exp < Date.now()) return null; + return payload; + } catch { + return null; + } +} + +export function issueSession() { + return sign({ user: 'admin', exp: Date.now() + 1000 * 60 * 60 * 24 * 30 }); +} + +export function isAdmin(req) { + const cookie = req.cookies?.wycz_session; + const payload = verify(cookie); + return payload && payload.user === 'admin'; +} + +export function adminPassword() { + return getSetting('admin_password'); +} diff --git a/backend/src/config.js b/backend/src/config.js new file mode 100644 index 0000000..135c926 --- /dev/null +++ b/backend/src/config.js @@ -0,0 +1,34 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const config = { + port: Number(process.env.PORT || 3004), + dataDir: process.env.DATA_DIR || path.join(__dirname, '..', 'data'), + dbPath: process.env.DB_PATH || path.join(process.env.DATA_DIR || path.join(__dirname, '..', 'data'), 'wycz.db'), + sessionSecret: process.env.SESSION_SECRET || 'wycz-session-secret-change-me', + adminPassword: process.env.ADMIN_PASSWORD || 'admin', + adminPath: process.env.ADMIN_PATH || '/admin', + blizzardClientId: process.env.BLIZZARD_CLIENT_ID || '', + blizzardClientSecret: process.env.BLIZZARD_CLIENT_SECRET || '', + fetchIntervalHours: Number(process.env.FETCH_INTERVAL_HOURS || 6), + publicUrl: process.env.PUBLIC_URL || 'http://localhost:3004', + wowToolsBuild: process.env.WOW_TOOLS_BUILD || '12.1.0.69111' +}; + +export const classColors = { + 'Death Knight': '#C41F3B', + 'Demon Hunter': '#A330C9', + 'Druid': '#FF7D0A', + 'Evoker': '#33937F', + 'Hunter': '#ABD473', + 'Mage': '#40C7EB', + 'Monk': '#00FF96', + 'Paladin': '#F58CBA', + 'Priest': '#FFFFFF', + 'Rogue': '#FFF569', + 'Shaman': '#0070DE', + 'Warlock': '#8787ED', + 'Warrior': '#C79C6E' +}; diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..b0eaa6f --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,201 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { config } from './config.js'; +import { hashPassword } from './auth.js'; + +fs.mkdirSync(path.dirname(config.dbPath), { recursive: true }); + +export const db = new Database(config.dbPath); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +db.exec(` +CREATE TABLE IF NOT EXISTS characters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + region TEXT NOT NULL DEFAULT 'eu', + realm TEXT NOT NULL, + name TEXT NOT NULL, + realm_slug TEXT, + class TEXT, + spec TEXT, + level INTEGER, + faction TEXT, + race TEXT, + race_id INTEGER, + gender INTEGER, + note TEXT DEFAULT '', + active INTEGER DEFAULT 1, + sort_order INTEGER DEFAULT 0, + data TEXT DEFAULT '{}', + last_updated TEXT, + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(region, realm, name) +); + +CREATE TABLE IF NOT EXISTS goals_config ( + key TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT DEFAULT '', + target REAL, + display_max REAL, + unit TEXT DEFAULT '', + enabled INTEGER DEFAULT 1, + sort_order INTEGER DEFAULT 0, + source TEXT DEFAULT 'mixed' +); + +CREATE TABLE IF NOT EXISTS goals_state ( + key TEXT PRIMARY KEY, + label TEXT, + current TEXT DEFAULT '{}', + progress REAL DEFAULT 0, + achieved INTEGER DEFAULT 0, + status TEXT DEFAULT 'ok', + detail TEXT DEFAULT '{}', + updated_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS settings ( + k TEXT PRIMARY KEY, + v TEXT +); + +CREATE TABLE IF NOT EXISTS item_cache ( + item_id INTEGER PRIMARY KEY, + display_id INTEGER, + display_type INTEGER, + resolved_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS fetch_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + character_id INTEGER, + source TEXT, + status TEXT, + detail TEXT, + at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS pvp_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + character_id INTEGER NOT NULL, + bracket TEXT NOT NULL, + rating INTEGER NOT NULL, + tier TEXT, + won INTEGER DEFAULT 0, + lost INTEGER DEFAULT 0, + at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_pvp_history_char ON pvp_history (character_id, bracket); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bracket TEXT NOT NULL, + won INTEGER NOT NULL, + at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_sessions_bracket ON sessions (bracket, at); + +CREATE TABLE IF NOT EXISTS character_titles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + character_id INTEGER NOT NULL, + title TEXT NOT NULL, + season TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_character_titles_char ON character_titles (character_id); + +CREATE TABLE IF NOT EXISTS talent_cache ( + key TEXT PRIMARY KEY, + data TEXT NOT NULL, + resolved_at TEXT DEFAULT (datetime('now')) +); +`); + +function seedGoals() { + const insert = db.prepare(`INSERT OR IGNORE INTO goals_config (key, title, description, target, display_max, unit, enabled, sort_order, source) VALUES (?,?,?,?,?,?,1,?,?)`); + const goals = [ + ['gladiator_title', 'Titre Gladiateur', 'Obtenir le titre Gladiateur sur un personnage', 1, 1, 'titre', 1, 'blizzard'], + ['mythic_plus_max', 'Score Mythic+ maximal', 'Monter le plus haut possible en Mythic+', null, 10000, 'score', 2, 'raiderio'], + ['all_classes_90', 'Toutes les classes niveau 90', 'Un personnage de chaque classe au niveau maximum', 90, 90, 'niveau', 3, 'mixed'], + ['blitz_2800', 'Blitz 2800', 'Atteindre 2800 de rating en Blitz', 2800, 3000, 'rating', 4, 'blizzard'], + ['shuffle_2300', 'Solo Shuffle 2300', 'Atteindre 2300 de rating en Solo Shuffle', 2300, 3000, 'rating', 5, 'blizzard'], + ['arena2v2_2300', '2v2 2300', 'Atteindre 2300 de rating en 2v2', 2300, 3000, 'rating', 6, 'blizzard'], + ['arena3v3_2300', '3v3 2300', 'Atteindre 2300 de rating en 3v3', 2300, 3000, 'rating', 7, 'blizzard'], + ['achievements', 'Hauts faits', 'Cumuler le maximum de points de hauts faits', null, 50000, 'points', 8, 'blizzard'], + ['mythic_raids', 'Raids en mode Mythique', 'Terminer tous les raids en mode Mythique', null, 1, 'raids', 9, 'raiderio'], + ['all_sets', 'Tous les sets', 'Collectionner tous les sets d\u2019armure du jeu', null, 1000, 'sets', 10, 'blizzard'] + ]; + for (const g of goals) insert.run(...g); +} + +function seedSettings() { + const get = db.prepare('SELECT v FROM settings WHERE k = ?'); + const set = db.prepare('INSERT OR IGNORE INTO settings (k, v) VALUES (?, ?)'); + if (!get.get('admin_password')) set.run('admin_password', hashPassword(config.adminPassword)); + if (!get.get('admin_path')) set.run('admin_path', config.adminPath); + if (!get.get('fetch_interval_hours')) set.run('fetch_interval_hours', String(config.fetchIntervalHours)); + if (!get.get('blizzard_client_id')) set.run('blizzard_client_id', config.blizzardClientId); + if (!get.get('blizzard_client_secret')) set.run('blizzard_client_secret', config.blizzardClientSecret); + if (!get.get('last_fetch_at')) set.run('last_fetch_at', ''); + if (!get.get('manual_class90_count')) set.run('manual_class90_count', ''); + if (!get.get('manual_gladiator_s2')) set.run('manual_gladiator_s2', ''); + if (!get.get('manual_mythic_bosses')) set.run('manual_mythic_bosses', ''); + if (!get.get('manual_achievements')) set.run('manual_achievements', ''); + if (!get.get('manual_achievements_total')) set.run('manual_achievements_total', '5843'); + if (!get.get('manual_class90_classes')) set.run('manual_class90_classes', ''); + if (!get.get('manual_ss2300_classes')) set.run('manual_ss2300_classes', ''); + if (!get.get('manual_glad_wins')) set.run('manual_glad_wins', ''); + if (!get.get('manual_glad_class')) set.run('manual_glad_class', ''); + if (!get.get('twitch_url')) set.run('twitch_url', 'https://twitch.tv/wycz'); + if (!get.get('youtube_url')) set.run('youtube_url', ''); + if (!get.get('tiktok_url')) set.run('tiktok_url', ''); + if (!get.get('mythic_raid_name')) set.run('mythic_raid_name', "L'Abîme Venimeux"); + if (!get.get('mythic_raid_total')) set.run('mythic_raid_total', '8'); + if (!get.get('glad_title_season')) set.run('glad_title_season', 'S1 Midnight'); + if (!get.get('legend_title_season')) set.run('legend_title_season', 'S1 Midnight'); +} + +seedGoals(); +seedSettings(); + +function migrate() { + const cols = db.prepare('PRAGMA table_info(characters)').all().map((c) => c.name); + if (!cols.includes('realm_slug')) { + db.exec('ALTER TABLE characters ADD COLUMN realm_slug TEXT'); + } + if (!cols.includes('pvp_sets')) { + db.exec('ALTER TABLE characters ADD COLUMN pvp_sets INTEGER DEFAULT 0'); + } + if (!cols.includes('pve_sets')) { + db.exec('ALTER TABLE characters ADD COLUMN pve_sets INTEGER DEFAULT 0'); + } + if (!cols.includes('pvp_manual')) { + db.exec('ALTER TABLE characters ADD COLUMN pvp_manual TEXT DEFAULT \'{}\''); + } + if (!cols.includes('check_pvp_url')) { + db.exec('ALTER TABLE characters ADD COLUMN check_pvp_url TEXT DEFAULT \'\''); + } + if (!cols.includes('glad_title')) { + db.exec('ALTER TABLE characters ADD COLUMN glad_title INTEGER DEFAULT 0'); + } + if (!cols.includes('legend_title')) { + db.exec('ALTER TABLE characters ADD COLUMN legend_title INTEGER DEFAULT 0'); + } +} + +migrate(); + +export function getSetting(k) { + const row = db.prepare('SELECT v FROM settings WHERE k = ?').get(k); + return row ? row.v : null; +} + +export function setSetting(k, v) { + db.prepare('INSERT INTO settings (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v').run(k, v); +} + +export function logFetch(characterId, source, status, detail) { + db.prepare('INSERT INTO fetch_log (character_id, source, status, detail) VALUES (?, ?, ?, ?)').run(characterId, source, status, JSON.stringify(detail)); +} diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js new file mode 100644 index 0000000..e8e835a --- /dev/null +++ b/backend/src/routes/admin.js @@ -0,0 +1,241 @@ +import { Router } from 'express'; +import { db, getSetting, setSetting } from '../db.js'; +import { isAdmin } from '../auth.js'; +import { refreshAll, refreshOne } from '../services/fetcher.js'; +import { hashPassword, verifyPassword, issueSession } from '../auth.js'; +import { recomputeGoals } from '../services/goals.js'; + +export const adminRouter = Router(); + +function requireAdmin(req, res, next) { + if (!isAdmin(req)) return res.status(401).json({ error: 'Non autorisé' }); + next(); +} + +adminRouter.post('/login', (req, res) => { + const { password } = req.body || {}; + const stored = getSetting('admin_password'); + if (!verifyPassword(password || '', stored)) { + return res.status(401).json({ error: 'Mot de passe incorrect' }); + } + res.cookie('wycz_session', issueSession(), { + httpOnly: true, + sameSite: 'lax', + maxAge: 30 * 24 * 60 * 60 * 1000, + secure: req.secure || req.get('x-forwarded-proto') === 'https' + }); + res.json({ ok: true }); +}); + +adminRouter.post('/logout', (req, res) => { + res.clearCookie('wycz_session'); + res.json({ ok: true }); +}); + +adminRouter.get('/me', requireAdmin, (req, res) => res.json({ admin: true })); + +adminRouter.get('/characters', requireAdmin, (req, res) => { + const chars = db.prepare('SELECT id, region, realm, name, class, spec, level, faction, race, race_id, gender, note, active, sort_order, pvp_sets, pve_sets, pvp_manual, check_pvp_url, glad_title, legend_title, last_updated, created_at FROM characters ORDER BY sort_order, name').all(); + const titles = db.prepare('SELECT id, character_id, title, season FROM character_titles ORDER BY id').all(); + const byChar = {}; + for (const t of titles) { + (byChar[t.character_id] = byChar[t.character_id] || []).push(t); + } + for (const c of chars) c.titles = byChar[c.id] || []; + res.json(chars); +}); + +adminRouter.post('/characters', requireAdmin, (req, res) => { + const { region, realm, name, note = '', realm_slug, class: cls } = req.body || {}; + if (!realm || !name) return res.status(400).json({ error: 'realm et name requis' }); + const maxSort = db.prepare('SELECT MAX(sort_order) as m FROM characters').get().m || 0; + try { + const info = db.prepare('INSERT INTO characters (region, realm, name, note, realm_slug, class, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)') + .run(region || 'eu', realm.trim(), name.trim(), note, realm_slug || null, cls || null, maxSort + 1); + res.json({ ok: true, id: info.lastInsertRowid }); + } catch (e) { + if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') return res.status(409).json({ error: 'Ce personnage existe déjà' }); + res.status(500).json({ error: e.message }); + } +}); + +adminRouter.put('/characters/:id', requireAdmin, (req, res) => { + const id = req.params.id; + const allowed = ['region', 'realm', 'name', 'realm_slug', 'note', 'active', 'sort_order', 'pvp_sets', 'pve_sets', 'pvp_manual', 'class', 'level', 'check_pvp_url', 'glad_title', 'legend_title']; + const sets = []; + const vals = []; + for (const k of allowed) { + if (k in (req.body || {})) { + sets.push(`${k} = ?`); + vals.push(req.body[k]); + } + } + if (!sets.length) return res.status(400).json({ error: 'rien à mettre à jour' }); + vals.push(id); + db.prepare(`UPDATE characters SET ${sets.join(', ')} WHERE id = ?`).run(...vals); + res.json({ ok: true }); +}); + +adminRouter.delete('/characters/:id', requireAdmin, (req, res) => { + db.prepare('DELETE FROM characters WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +adminRouter.post('/characters/:id/titles', requireAdmin, (req, res) => { + const { title, season = '' } = req.body || {}; + if (!['glad', 'legend'].includes(title)) return res.status(400).json({ error: 'title doit être glad ou legend' }); + const char = db.prepare('SELECT id FROM characters WHERE id = ?').get(req.params.id); + if (!char) return res.status(404).json({ error: 'Introuvable' }); + const info = db.prepare('INSERT INTO character_titles (character_id, title, season) VALUES (?, ?, ?)').run(char.id, title, String(season).trim()); + res.json({ ok: true, id: info.lastInsertRowid }); +}); + +adminRouter.delete('/characters/:id/titles/:titleId', requireAdmin, (req, res) => { + db.prepare('DELETE FROM character_titles WHERE id = ? AND character_id = ?').run(req.params.titleId, req.params.id); + res.json({ ok: true }); +}); + +adminRouter.post('/refresh', requireAdmin, async (req, res) => { + const { id } = req.body || {}; + res.json({ ok: true, started: true }); + try { + if (id) { + await refreshOne(id); + } else { + await refreshAll(); + } + } catch (e) { + console.error('[refresh]', e.message); + } +}); + +adminRouter.get('/goals', requireAdmin, (req, res) => { + const goals = db.prepare(` + SELECT g.*, s.current, s.progress, s.achieved, s.status, s.updated_at + FROM goals_config g LEFT JOIN goals_state s ON g.key = s.key + ORDER BY g.sort_order`).all(); + res.json(goals); +}); + +adminRouter.put('/goals/:key', requireAdmin, (req, res) => { + const { key } = req.params; + const allowed = ['title', 'description', 'target', 'display_max', 'unit', 'enabled', 'sort_order', 'source']; + const sets = []; + const vals = []; + for (const k of allowed) { + if (k in (req.body || {})) { + sets.push(`${k} = ?`); + vals.push(req.body[k]); + } + } + if (!sets.length) return res.status(400).json({ error: 'rien à mettre à jour' }); + vals.push(key); + db.prepare(`UPDATE goals_config SET ${sets.join(', ')} WHERE key = ?`).run(...vals); + recomputeGoals(); + res.json({ ok: true }); +}); + +adminRouter.get('/settings', requireAdmin, (req, res) => { + res.json({ + fetch_interval_hours: getSetting('fetch_interval_hours'), + blizzard_client_id: getSetting('blizzard_client_id'), + blizzard_client_secret: getSetting('blizzard_client_secret') ? '********' : '', + blizzard_configured: !!getSetting('blizzard_client_secret'), + last_fetch_at: getSetting('last_fetch_at'), + wow_tools_build: getSetting('wow_tools_build'), + manual_class90_count: getSetting('manual_class90_count') || '', + manual_class90_classes: getSetting('manual_class90_classes') || '', + manual_ss2300_classes: getSetting('manual_ss2300_classes') || '', + manual_gladiator_s2: getSetting('manual_gladiator_s2') || '', + manual_glad_wins: getSetting('manual_glad_wins') || '', + manual_glad_class: getSetting('manual_glad_class') || '', + manual_mythic_bosses: getSetting('manual_mythic_bosses') || '', + manual_achievements: getSetting('manual_achievements') || '', + manual_achievements_total: getSetting('manual_achievements_total') || '', + twitch_url: getSetting('twitch_url') || '', + youtube_url: getSetting('youtube_url') || '', + tiktok_url: getSetting('tiktok_url') || '', + mythic_raid_name: getSetting('mythic_raid_name') || '', + mythic_raid_total: getSetting('mythic_raid_total') || '', + glad_title_season: getSetting('glad_title_season') || '', + legend_title_season: getSetting('legend_title_season') || '' + }); +}); + +adminRouter.put('/settings', requireAdmin, (req, res) => { + const { fetch_interval_hours, blizzard_client_id, blizzard_client_secret, wow_tools_build, + manual_class90_count, manual_class90_classes, manual_ss2300_classes, manual_gladiator_s2, manual_glad_wins, manual_glad_class, + manual_mythic_bosses, manual_achievements, manual_achievements_total, + twitch_url, youtube_url, tiktok_url, mythic_raid_name, mythic_raid_total, + glad_title_season, legend_title_season } = req.body || {}; + if (fetch_interval_hours) setSetting('fetch_interval_hours', String(fetch_interval_hours)); + if (blizzard_client_id !== undefined) setSetting('blizzard_client_id', blizzard_client_id); + if (blizzard_client_secret && blizzard_client_secret !== '********') setSetting('blizzard_client_secret', blizzard_client_secret); + if (wow_tools_build) setSetting('wow_tools_build', wow_tools_build); + if (manual_class90_count !== undefined) setSetting('manual_class90_count', String(manual_class90_count).trim()); + if (manual_class90_classes !== undefined) setSetting('manual_class90_classes', String(manual_class90_classes).trim()); + if (manual_ss2300_classes !== undefined) setSetting('manual_ss2300_classes', String(manual_ss2300_classes).trim()); + if (manual_gladiator_s2 !== undefined) setSetting('manual_gladiator_s2', manual_gladiator_s2 ? '1' : ''); + if (manual_glad_wins !== undefined) setSetting('manual_glad_wins', String(manual_glad_wins).trim()); + if (manual_glad_class !== undefined) setSetting('manual_glad_class', String(manual_glad_class).trim()); + if (manual_mythic_bosses !== undefined) setSetting('manual_mythic_bosses', String(manual_mythic_bosses).trim()); + if (manual_achievements !== undefined) setSetting('manual_achievements', String(manual_achievements).trim()); + if (twitch_url !== undefined) setSetting('twitch_url', String(twitch_url).trim()); + if (youtube_url !== undefined) setSetting('youtube_url', String(youtube_url).trim()); + if (tiktok_url !== undefined) setSetting('tiktok_url', String(tiktok_url).trim()); + if (mythic_raid_name !== undefined) setSetting('mythic_raid_name', String(mythic_raid_name).trim()); + if (mythic_raid_total !== undefined) setSetting('mythic_raid_total', String(mythic_raid_total).trim()); + if (glad_title_season !== undefined) setSetting('glad_title_season', String(glad_title_season).trim()); + if (legend_title_season !== undefined) setSetting('legend_title_season', String(legend_title_season).trim()); + res.json({ ok: true }); +}); + +adminRouter.post('/password', requireAdmin, (req, res) => { + const { current, next } = req.body || {}; + const stored = getSetting('admin_password'); + if (!verifyPassword(current || '', stored)) return res.status(401).json({ error: 'Mot de passe actuel incorrect' }); + if (!next || next.length < 6) return res.status(400).json({ error: 'Nouveau mot de passe trop court (min 6 caractères)' }); + setSetting('admin_password', hashPassword(next)); + res.json({ ok: true }); +}); + +adminRouter.get('/logs', requireAdmin, (req, res) => { + const logs = db.prepare('SELECT * FROM fetch_log ORDER BY id DESC LIMIT 100').all(); + res.json(logs); +}); + +adminRouter.post('/sessions', requireAdmin, (req, res) => { + const { bracket, won } = req.body || {}; + if (!bracket || (won !== 1 && won !== 0)) return res.status(400).json({ error: 'bracket et won (0/1) requis' }); + db.prepare('INSERT INTO sessions (bracket, won) VALUES (?, ?)').run(String(bracket), won ? 1 : 0); + res.json({ ok: true, sessions: sessionSummary() }); +}); + +adminRouter.post('/sessions/undo', requireAdmin, (req, res) => { + const { bracket } = req.body || {}; + if (bracket) { + db.prepare('DELETE FROM sessions WHERE id = (SELECT id FROM sessions WHERE bracket = ? ORDER BY id DESC LIMIT 1)').run(String(bracket)); + } else { + db.prepare('DELETE FROM sessions WHERE id = (SELECT id FROM sessions ORDER BY id DESC LIMIT 1)').run(); + } + res.json({ ok: true, sessions: sessionSummary() }); +}); + +adminRouter.get('/sessions', requireAdmin, (req, res) => { + res.json(sessionSummary()); +}); + +function sessionSummary() { + const today = db.prepare(` + SELECT bracket, SUM(won) AS wins, COUNT(*) - SUM(won) AS losses + FROM sessions + WHERE date(at) = date('now', 'localtime') + GROUP BY bracket`).all(); + const week = db.prepare(` + SELECT bracket, SUM(won) AS wins, COUNT(*) - SUM(won) AS losses + FROM sessions + WHERE at >= datetime('now', 'localtime', '-7 days') + GROUP BY bracket`).all(); + const last = db.prepare('SELECT bracket, won, at FROM sessions ORDER BY id DESC LIMIT 20').all(); + return { today, week, last }; +} diff --git a/backend/src/routes/public.js b/backend/src/routes/public.js new file mode 100644 index 0000000..a2acba4 --- /dev/null +++ b/backend/src/routes/public.js @@ -0,0 +1,493 @@ +import { Router } from 'express'; +import { db, getSetting } from '../db.js'; +import { recomputeGoals } from '../services/goals.js'; +import { apiStatic, SPEC_IDS } from '../services/blizzard.js'; + +export const publicRouter = Router(); + +publicRouter.get('/overview', (req, res) => { + const chars = db.prepare('SELECT * FROM characters WHERE active = 1').all().map((c) => { + const data = safeParse(c.data); + return { ...c, data }; + }); + + const manualClass90 = parseInt(getSetting('manual_class90_count') || '', 10); + const manualClass90List = (getSetting('manual_class90_classes') || '').split(',').map((s) => s.trim()).filter(Boolean); + const manualSS2300List = (getSetting('manual_ss2300_classes') || '').split(',').map((s) => s.trim()).filter(Boolean); + const manualGlad = getSetting('manual_gladiator_s2') || ''; + const manualGladWins = parseInt(getSetting('manual_glad_wins') || '', 10); + const manualBosses = parseInt(getSetting('manual_mythic_bosses') || '', 10); + const manualAch = parseInt(getSetting('manual_achievements') || '', 10); + + const classes90 = new Set(); + const classesSS2300 = new Set(); + let max3v3 = null; + let max3v3Char = null; + const bests = {}; + const track = (bracket, rating, meta) => { + if (!rating) return; + if (!bests[bracket] || rating > bests[bracket].rating) bests[bracket] = { rating, ...meta }; + }; + for (const c of chars) { + const cls = c.data?.blizzard?.class || c.data?.raiderio?.class || c.class; + const level = c.data?.blizzard?.level ?? c.data?.raiderio?.level ?? c.level ?? null; + if (cls && typeof level === 'number' && level >= 90) classes90.add(cls); + let ss = null; + let a3 = null; + const pm = safeParse(c.pvp_manual); + if (pm.SOLO_SHUFFLE) ss = pm.SOLO_SHUFFLE.season || null; + if (pm.ARENA_3v3) a3 = Math.max(pm.ARENA_3v3.season || 0, pm.ARENA_3v3.max || 0) || null; + if (pm.specs) { + for (const k of Object.keys(pm.specs)) { + const s = pm.specs[k].SOLO_SHUFFLE || {}; + const v = s.season || null; + if (v && v > (ss || 0)) ss = v; + const trackSpec = (br) => { + const m = (pm.specs[k][br] || {}); + const r = m.max || m.season || null; + if (r) track(br, r, { name: c.name, class: cls, spec: k }); + }; + trackSpec('SOLO_SHUFFLE'); + trackSpec('BLITZ'); + } + } + const autoSolo = c.data?.blizzard?.pvp?.SOLO_SHUFFLE || c.data?.raiderio?.pvp?.SOLO_SHUFFLE || {}; + if (ss === null && typeof autoSolo.rating === 'number') ss = autoSolo.rating; + const autoArena = c.data?.blizzard?.pvp?.ARENA_3v3 || c.data?.raiderio?.pvp?.ARENA_3v3 || {}; + if (a3 === null && typeof autoArena.rating === 'number') a3 = autoArena.rating; + if (cls && typeof ss === 'number' && ss >= 2300) classesSS2300.add(cls); + if (typeof a3 === 'number' && a3 > (max3v3 ?? 0)) { max3v3 = a3; max3v3Char = cls; } + for (const k of ['ARENA_2v2', 'ARENA_3v3', 'RATED_BG']) { + const m = pm[k] || {}; + const auto = c.data?.blizzard?.pvp?.[k] || c.data?.raiderio?.pvp?.[k] || {}; + const r = m.max || m.season || (typeof auto.rating === 'number' ? auto.rating : null) || null; + if (r) track(k, r, { name: c.name, class: cls, spec: c.spec || null }); + } + } + + const class90List = manualClass90List.length ? manualClass90List + : (Number.isFinite(manualClass90) ? [] : [...classes90]); + const class90Covered = manualClass90List.length ? manualClass90List.length + : (Number.isFinite(manualClass90) ? Math.max(0, Math.min(13, manualClass90)) : classes90.size); + const class90Manual = manualClass90List.length > 0 || Number.isFinite(manualClass90); + + const ss2300List = manualSS2300List.length ? manualSS2300List : [...classesSS2300]; + const ss2300Covered = manualSS2300List.length ? manualSS2300List.length : classesSS2300.size; + const ss2300Manual = manualSS2300List.length > 0; + + const gladWins = Number.isFinite(manualGladWins) ? Math.max(0, Math.min(50, manualGladWins)) : 0; + + const mythicRaidName = getSetting('mythic_raid_name') || "L'Abîme Venimeux"; + const mythicRaidTotal = parseInt(getSetting('mythic_raid_total') || '8', 10); + let autoBosses = null; + let autoAch = null; + let glads = []; + for (const c of chars) { + const blizz = c.data?.blizzard || {}; + const ri = c.data?.raiderio || {}; + if (Array.isArray(blizz.titles)) glads = glads.concat(blizz.titles); + const ach = blizz.achievement_count ?? null; + if (typeof ach === 'number') autoAch = Math.max(autoAch ?? 0, ach); + const raids = ri.raid_progression || {}; + const lastRaid = Object.entries(raids) + .filter(([, r]) => r?.mythic?.total > 0) + .sort(([, a], [, b]) => b.mythic.total - a.mythic.total)[0]; + if (lastRaid && typeof lastRaid[1].mythic.killed === 'number') { + autoBosses = Math.max(autoBosses ?? 0, lastRaid[1].mythic.killed); + } + } + + const gladiatorFound = glads.some((t) => typeof t === 'string' && /gladiateur/i.test(t)); + const gladObtained = manualGlad === '1' || gladWins >= 50 || (manualGlad === '' && manualGladWins === '' && gladiatorFound); + + const sessions = sessionSummary(); + + res.json({ + class90: { covered: class90Covered, total: 13, manual: class90Manual, classes: class90List }, + ss2300: { covered: ss2300Covered, total: 13, manual: ss2300Manual, classes: ss2300List }, + gladiator_s2: { + obtained: gladObtained, + wins: gladWins, + total: 50, + rating: max3v3, + class: getSetting('manual_glad_class') || max3v3Char || null + }, + mythic_raid: { + name: mythicRaidName, + total: mythicRaidTotal, + killed: Number.isFinite(manualBosses) ? manualBosses : (autoBosses ?? 0), + manual: Number.isFinite(manualBosses) + }, + achievements: { + points: Number.isFinite(manualAch) ? manualAch : (autoAch ?? 0), + total: parseInt(getSetting('manual_achievements_total') || '5843', 10) || 5843, + manual: Number.isFinite(manualAch) + }, + twitch: { url: getSetting('twitch_url') || 'https://twitch.tv/wycz' }, + youtube: { url: getSetting('youtube_url') || '' }, + tiktok: { url: getSetting('tiktok_url') || '' }, + titles: titlesOverview(chars), + bests, + sessions + }); +}); + +function titlesOverview(chars) { + const entries = db.prepare(` + SELECT ct.character_id, ct.title, ct.season, c.class + FROM character_titles ct + JOIN characters c ON c.id = ct.character_id + WHERE c.active = 1 + ORDER BY ct.id`).all(); + const classOf = (c) => c.data?.blizzard?.class || c.data?.raiderio?.class || c.class; + const byTitle = { glad: [], legend: [] }; + for (const e of entries) { + const cls = classOf(chars.find((c) => c.id === e.character_id) || { class: e.class }); + if (!cls) continue; + if (e.title === 'glad') byTitle.glad.push({ class: cls, season: e.season || '' }); + else if (e.title === 'legend') byTitle.legend.push({ class: cls, season: e.season || '' }); + } + const isS2 = (s) => /s2/i.test(s || ''); + if (!entries.length) { + const gladSeasonS2 = isS2(getSetting('glad_title_season')); + for (const c of chars) { + const cls = classOf(c); + if (!cls) continue; + if (c.glad_title && gladSeasonS2) byTitle.glad.push({ class: cls, season: getSetting('glad_title_season') || '' }); + if (c.legend_title) byTitle.legend.push({ class: cls, season: getSetting('legend_title_season') || '' }); + } + } + return { + glad: byTitle.glad.map((e) => e.class), + legend: byTitle.legend.map((e) => e.class), + glad_s2: byTitle.glad.filter((e) => isS2(e.season)).map((e) => e.class), + glad_season: getSetting('glad_title_season') || '', + legend_season: getSetting('legend_title_season') || '', + glad_entries: byTitle.glad, + legend_entries: byTitle.legend + }; +} + +function sessionSummary() { + const rows = db.prepare('SELECT bracket, won, at FROM sessions ORDER BY id DESC LIMIT 500').all(); + const today = {}; + const week = {}; + const now = new Date(); + const todayStr = now.toISOString().slice(0, 10); + const weekAgo = new Date(now.getTime() - 7 * 24 * 3600 * 1000).toISOString(); + for (const r of rows) { + const at = r.at.replace(' ', 'T') + 'Z'; + const d = new Date(at); + const day = d.toISOString().slice(0, 10); + if (day === todayStr) { + today[r.bracket] = today[r.bracket] || { wins: 0, losses: 0 }; + if (r.won) today[r.bracket].wins++; else today[r.bracket].losses++; + } + if (at >= weekAgo) { + week[r.bracket] = week[r.bracket] || { wins: 0, losses: 0 }; + if (r.won) week[r.bracket].wins++; else week[r.bracket].losses++; + } + } + const perBracket = {}; + const order = []; + for (const r of rows) { + if (!(r.bracket in perBracket)) { + perBracket[r.bracket] = { wins: 0, losses: 0, streak: 0, streakKind: null }; + order.push(r.bracket); + } + const b = perBracket[r.bracket]; + if (b.streak === 0 || b.streakKind === r.won) { + b.streak = r.won ? b.streak + 1 : b.streak - 1; + b.streakKind = r.won; + } + } + const out = {}; + for (const br of order) { + out[br] = { today: today[br] || { wins: 0, losses: 0 }, week: week[br] || { wins: 0, losses: 0 }, streak: perBracket[br].streak }; + } + return out; +} + +publicRouter.get('/goals', (req, res) => { + recomputeGoals(); + const goals = db.prepare(` + SELECT g.key, g.title, g.description, g.target, g.display_max, g.unit, g.source, + s.current, s.progress, s.achieved, s.status, s.updated_at + FROM goals_config g LEFT JOIN goals_state s ON g.key = s.key + WHERE g.enabled = 1 + ORDER BY g.sort_order`).all(); + res.json(goals); +}); + +publicRouter.get('/characters', (req, res) => { + const chars = db.prepare('SELECT * FROM characters WHERE active = 1 ORDER BY sort_order, name').all().map((c) => { + const data = safeParse(c.data); + return publicCharacter(c, data); + }); + res.json(chars); +}); + +publicRouter.get('/characters/:id', (req, res) => { + const c = db.prepare('SELECT * FROM characters WHERE id = ? AND active = 1').get(req.params.id); + if (!c) return res.status(404).json({ error: 'Introuvable' }); + const data = safeParse(c.data); + res.json(publicCharacter(c, data, true)); +}); + +publicRouter.get('/characters/:id/pvp-history', (req, res) => { + const c = db.prepare('SELECT * FROM characters WHERE id = ? AND active = 1').get(req.params.id); + if (!c) return res.status(404).json({ error: 'Introuvable' }); + const rows = db.prepare('SELECT bracket, rating, tier, won, lost, at FROM pvp_history WHERE character_id = ? ORDER BY at ASC').all(c.id); + const series = {}; + const best = {}; + for (const r of rows) { + if (!series[r.bracket]) series[r.bracket] = []; + series[r.bracket].push({ at: r.at, rating: r.rating }); + if (!(r.bracket in best) || r.rating > best[r.bracket]) best[r.bracket] = r.rating; + } + res.json({ series, best }); +}); + +publicRouter.get('/characters/:id/model3d', async (req, res) => { + const c = db.prepare('SELECT * FROM characters WHERE id = ? AND active = 1').get(req.params.id); + if (!c) return res.status(404).json({ error: 'Introuvable' }); + const data = safeParse(c.data); + const blizz = data.blizzard || {}; + const raider = data.raiderio || {}; + const model = { + race: blizz.race_id ?? raider.race_id ?? c.race_id ?? null, + gender: blizz.gender ?? raider.gender ?? c.gender ?? 0, + appearance: blizz.appearance || null, + items: (data.model3d && data.model3d.items) || [] + }; + if (model.race === null) { + return res.status(404).json({ error: 'Modèle non disponible (race inconnue)' }); + } + res.json(model); +}); + +publicRouter.get('/characters/:id/talents', async (req, res) => { + const c = db.prepare('SELECT * FROM characters WHERE id = ? AND active = 1').get(req.params.id); + if (!c) return res.status(404).json({ error: 'Introuvable' }); + const data = safeParse(c.data); + const spec = req.query.spec || data.blizzard?.spec || data.raiderio?.spec || c.spec || null; + const cls = data.blizzard?.class || data.raiderio?.class || c.class || null; + if (!cls || !spec) return res.status(400).json({ error: 'Classe/spécialisation inconnue' }); + + const specializations = data.blizzard?.specializations; + const specId = SPEC_IDS[cls]?.[spec]; + if (!specId) return res.status(400).json({ error: `Spécialisation ${spec} inconnue` }); + const specEntry = (specializations?.specs || []).find((s) => s.spec_id === specId) || null; + const selected = specEntry ? { class: [], spec: [] } : null; + if (specEntry?.talents) { + for (const t of specEntry.talents) { + if (t.tree === 'spec') selected.spec.push(t); + else selected.class.push(t); + } + } + + try { + const treeIds = []; + if (specEntry?.class_tree_id) treeIds.push([specEntry.class_tree_id, 'class']); + if (specEntry?.spec_tree_id) treeIds.push([specEntry.spec_tree_id, 'spec']); + if (!treeIds.length) { + const resolved = await resolveTreeIds(c.region, specId); + for (const [tid, kind] of resolved) treeIds.push([tid, kind]); + } + if (!treeIds.length) return res.status(404).json({ error: 'Arbre de talents introuvable' }); + + const trees = {}; + for (const [treeId, kind] of treeIds) { + trees[kind] = await loadTalentTree(c.region, treeId, specId, kind); + } + res.json({ + cls, + spec, + spec_id: specId, + active: specializations?.active_spec_id != null + ? (specEntry?.spec_id === specializations.active_spec_id) + : (specEntry?.spec === (specializations?.active_spec ?? null)), + loadout_text: specEntry?.loadout_text || null, + selected, + trees + }); + } catch (e) { + res.status(e.code === 'NOT_CONFIGURED' ? 503 : 502).json({ + error: e.code === 'NOT_CONFIGURED' ? 'API Blizzard non configurée' : `Impossible de récupérer l'arbre de talents (${e.message})` + }); + } +}); + +async function resolveTreeIds(region, specId) { + const index = await loadTalentTreeIndex(region); + const entry = (index.spec_talent_trees || []).find((e) => { + const m = /\/playable-specialization\/(\d+)/.exec(e.key?.href || ''); + return m && Number(m[1]) === specId; + }); + const treeId = treeIdFromHref(entry?.key?.href); + if (!treeId) return []; + return [[treeId, 'class'], [treeId, 'spec']]; +} + +async function loadTalentTreeIndex(region) { + const cached = db.prepare('SELECT data FROM talent_cache WHERE key = ?').get('talent-tree-index'); + if (cached) return JSON.parse(cached.data); + const index = await apiStatic(region, '/data/wow/talent-tree/index'); + const out = { spec_talent_trees: index.spec_talent_trees || [], class_talent_trees: index.class_talent_trees || [] }; + db.prepare('INSERT INTO talent_cache (key, data) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET data = excluded.data, resolved_at = datetime(\'now\')').run('talent-tree-index', JSON.stringify(out)); + return out; +} + +function treeIdFromHref(href) { + const m = /\/talent-tree\/(\d+)/.exec(href || ''); + return m ? Number(m[1]) : null; +} + +async function loadTalentTree(region, treeId, specId, kind) { + const cacheKey = `tree-${kind}-${treeId}-${specId || ''}`; + const cached = db.prepare('SELECT data FROM talent_cache WHERE key = ?').get(cacheKey); + if (cached) return JSON.parse(cached.data); + const path = `/data/wow/talent-tree/${treeId}/playable-specialization/${specId}`; + const tt = await apiStatic(region, path); + const raw = (kind === 'spec' ? tt.spec_talent_nodes : tt.class_talent_nodes) || tt.talent_nodes || tt.nodes || []; + const nodes = raw.map((n) => { + const ranks = n.ranks || []; + const first = ranks[0] || {}; + const tooltip = first.tooltip || {}; + const talent = tooltip.talent || {}; + const st = tooltip.spell_tooltip || {}; + const spell = st.spell || {}; + return { + id: n.id, + node_type: n.node_type?.type || n.node_type || null, + rank: ranks.length || 1, + max_rank: ranks.length || 1, + x: n.display_col ?? 0, + y: n.display_row ?? 0, + talent_id: talent.id ?? null, + talent_name: talent.name || spell.name || null, + spell_id: spell.id ?? null, + spell_name: spell.name || null, + icon: null, + tooltip: st.description || null, + cast_time: st.cast_time || null, + connected: n.unlocks || [] + }; + }); + const icons = await resolveSpellIcons(region, [...new Set(nodes.map((n) => n.spell_id).filter(Boolean))]); + for (const n of nodes) { + if (n.spell_id && icons[n.spell_id]) n.icon = icons[n.spell_id]; + } + const out = { + id: treeId, + name: tt.name || null, + width: Math.max(0, ...nodes.map((n) => n.x)), + height: Math.max(0, ...nodes.map((n) => n.y)), + nodes + }; + db.prepare('INSERT INTO talent_cache (key, data) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET data = excluded.data, resolved_at = datetime(\'now\')').run(cacheKey, JSON.stringify(out)); + return out; +} + +async function resolveSpellIcons(region, spellIds) { + const out = {}; + const missing = []; + for (const sid of spellIds) { + const hit = db.prepare('SELECT data FROM talent_cache WHERE key = ?').get(`icon-${sid}`); + if (hit) { + try { out[sid] = JSON.parse(hit.data); } catch {} + } else { + missing.push(sid); + } + } + for (let i = 0; i < missing.length; i += 8) { + const batch = missing.slice(i, i + 8); + const results = await Promise.allSettled(batch.map(async (sid) => { + const m = await apiStatic(region, `/data/wow/media/spell/${sid}`); + const asset = (m.assets || []).find((a) => a.key === 'icon'); + const url = asset?.value || null; + if (url) { + db.prepare('INSERT INTO talent_cache (key, data) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET data = excluded.data, resolved_at = datetime(\'now\')').run(`icon-${sid}`, JSON.stringify(url)); + } + return [sid, url]; + })); + for (const r of results) { + if (r.status === 'fulfilled' && r.value && r.value[1]) out[r.value[0]] = r.value[1]; + } + } + return out; +} + +function safeParse(str) { + try { + return JSON.parse(str || '{}'); + } catch { + return {}; + } +} + +function pvpHistorySummary(characterId) { + const rows = db.prepare('SELECT bracket, rating, at FROM pvp_history WHERE character_id = ? ORDER BY id ASC').all(characterId); + const byBracket = {}; + const best = {}; + const records = {}; + for (const r of rows) { + if (!byBracket[r.bracket]) byBracket[r.bracket] = []; + byBracket[r.bracket].push(r); + } + for (const [br, pts] of Object.entries(byBracket)) { + let max = 0; + for (const p of pts) if (p.rating > max) max = p.rating; + best[br] = max; + const last = pts[pts.length - 1]; + const prev = pts[pts.length - 2]; + if (max > 0 && last.rating === max && prev && prev.rating < max) records[br] = true; + } + return { pvp_best: best, pvp_records: records }; +} + +function publicCharacter(c, data, full = false) { + const raider = data.raiderio || {}; + const blizz = data.blizzard || {}; + const pvp = blizz.pvp || {}; + const gear = full ? raider.gear || blizz.equipment || [] : null; + const model3d = full ? (data.model3d || null) : null; + const { pvp_best, pvp_records } = pvpHistorySummary(c.id); + return { + id: c.id, + region: c.region, + realm: c.realm, + name: c.name, + note: c.note, + class: blizz.class || raider.class || c.class || null, + spec: blizz.spec || raider.spec || c.spec || null, + level: blizz.level ?? raider.level ?? c.level ?? null, + faction: blizz.faction || raider.faction || c.faction || null, + race: blizz.race || raider.race || c.race || null, + gender: blizz.gender ?? raider.gender ?? c.gender ?? 0, + ilvl: raider.ilvl || 0, + achievement_points: blizz.achievement_points ?? raider.achievement_points ?? 0, + mythic_plus_scores: raider.mythic_plus_scores || null, + mythic_plus_best_runs: raider.mythic_plus_best_runs || [], + raid_progression: raider.raid_progression || null, + pvp, + pvp_best, + pvp_records, + pvp_manual: safeParse(c.pvp_manual || '{}'), + thumbnail_url: raider.thumbnail_url || null, + media: blizz.media || null, + pvp_sets: c.pvp_sets ?? '', + pve_sets: c.pve_sets ?? '', + check_pvp_url: c.check_pvp_url || '', + glad_title: !!c.glad_title, + legend_title: !!c.legend_title, + last_updated: c.last_updated, + gear, + model3d, + specializations: full ? (data.blizzard?.specializations || null) : null, + sources: { + raiderio: !!data.raiderio, + blizzard: !!data.blizzard + } + }; +} diff --git a/backend/src/scheduler.js b/backend/src/scheduler.js new file mode 100644 index 0000000..066d76d --- /dev/null +++ b/backend/src/scheduler.js @@ -0,0 +1,39 @@ +import { getSetting } from './db.js'; +import { refreshAll } from './services/fetcher.js'; +import { recomputeGoals } from './services/goals.js'; + +let timer = null; + +function intervalHours() { + return Math.max(1, Number(getSetting('fetch_interval_hours')) || 6); +} + +export function scheduleFetch() { + if (timer) clearInterval(timer); + const ms = intervalHours() * 60 * 60 * 1000; + timer = setInterval(async () => { + console.log('[scheduler] refresh all characters'); + try { + await refreshAll(); + } catch (e) { + console.error('[scheduler] error', e.message); + } + }, ms); +} + +export async function initialRefresh() { + const last = getSetting('last_fetch_at'); + const chars = 0; + try { + if (!last) { + console.log('[scheduler] first run, fetching all characters'); + await refreshAll(); + } else { + recomputeGoals(); + console.log('[scheduler] data exists, computing goals'); + } + } catch (e) { + console.error('[scheduler] initial refresh error', e.message); + } + return chars; +} diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..ac83227 --- /dev/null +++ b/backend/src/server.js @@ -0,0 +1,72 @@ +import express from 'express'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { config } from './config.js'; +import { getSetting } from './db.js'; +import { adminRouter } from './routes/admin.js'; +import { publicRouter } from './routes/public.js'; +import { scheduleFetch, initialRefresh } from './scheduler.js'; +import { refreshAll } from './services/fetcher.js'; +import { proxyModelViewer } from './services/modelviewerProxy.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const publicDir = path.join(__dirname, '..', 'public'); + +const app = express(); +app.disable('x-powered-by'); +app.use(express.json({ limit: '1mb' })); + +app.use((req, res, next) => { + const header = req.headers.cookie; + const cookies = {}; + if (header) { + for (const part of header.split(';')) { + const idx = part.indexOf('='); + if (idx > 0) cookies[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim()); + } + } + req.cookies = cookies; + next(); +}); + +app.use('/api', publicRouter); + +const adminPath = () => getSetting('admin_path') || config.adminPath; + +app.use('/api/admin', adminRouter); + +app.get('/mv/*', proxyModelViewer); + +app.get(adminPath(), (req, res) => { + res.sendFile(path.join(publicDir, 'admin.html')); +}); + +app.get('/', (req, res) => { + res.sendFile(path.join(publicDir, 'index.html')); +}); + +app.get('/manifest.webmanifest', (req, res) => { + res.type('application/manifest+json'); + res.sendFile(path.join(publicDir, 'manifest.webmanifest')); +}); + +app.get('/sw.js', (req, res) => { + res.set('Cache-Control', 'no-cache'); + res.type('application/javascript'); + res.sendFile(path.join(publicDir, 'sw.js')); +}); + +app.use(express.static(publicDir, { extensions: ['html'] })); + +app.use((req, res) => res.status(404).json({ error: 'Not found' })); + +app.listen(config.port, () => { + console.log(`Wycz listening on port ${config.port}`); + console.log(`Admin: /admin (configurable via settings)`); + scheduleFetch(); + initialRefresh().catch((e) => console.error(e)); +}); + +// initial refresh already runs on first boot; if no characters exist, nothing to do. +process.on('SIGTERM', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); diff --git a/backend/src/services/blizzard.js b/backend/src/services/blizzard.js new file mode 100644 index 0000000..396c812 --- /dev/null +++ b/backend/src/services/blizzard.js @@ -0,0 +1,277 @@ +import { getSetting } from '../db.js'; +import { slugifyRealm } from './raiderio.js'; + +let cachedToken = { token: null, expiresAt: 0 }; + +export function isConfigured() { + return !!(getSetting('blizzard_client_id') && getSetting('blizzard_client_secret')); +} + +async function getToken() { + if (cachedToken.token && Date.now() < cachedToken.expiresAt) return cachedToken.token; + const res = await fetch('https://oauth.battle.net/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: 'Basic ' + Buffer.from(`${getSetting('blizzard_client_id')}:${getSetting('blizzard_client_secret')}`).toString('base64') + }, + body: 'grant_type=client_credentials' + }); + if (!res.ok) { + const err = new Error(`Blizzard OAuth HTTP ${res.status}`); + err.code = 'AUTH_ERROR'; + throw err; + } + const data = await res.json(); + cachedToken = { token: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 - 60000 }; + return cachedToken.token; +} + +async function api(region, path) { + const token = await getToken(); + const url = `https://${region}.api.blizzard.com${path}`; + const params = new URLSearchParams({ + namespace: `profile-${region}`, + locale: 'fr_FR' + }); + const res = await fetch(`${url}?${params.toString()}`, { headers: { 'User-Agent': 'wycz-app/1.0', Authorization: `Bearer ${token}` } }); + if (res.status === 404) { + const err = new Error(`Blizzard 404 pour ${path}`); + err.code = 'NOT_FOUND'; + throw err; + } + if (!res.ok) { + const err = new Error(`Blizzard HTTP ${res.status} pour ${path}`); + err.code = 'HTTP_ERROR'; + throw err; + } + return res.json(); +} + +export async function apiStatic(region, path) { + if (!isConfigured()) { + const err = new Error('API Blizzard non configurée'); + err.code = 'NOT_CONFIGURED'; + throw err; + } + const token = await getToken(); + const url = `https://${region}.api.blizzard.com${path}`; + const params = new URLSearchParams({ + namespace: `static-${region}`, + locale: 'fr_FR' + }); + const res = await fetch(`${url}?${params.toString()}`, { headers: { 'User-Agent': 'wycz-app/1.0', Authorization: `Bearer ${token}` } }); + if (res.status === 404) { + const err = new Error(`Blizzard 404 pour ${path}`); + err.code = 'NOT_FOUND'; + throw err; + } + if (!res.ok) { + const err = new Error(`Blizzard HTTP ${res.status} pour ${path}`); + err.code = 'HTTP_ERROR'; + throw err; + } + return res.json(); +} + +export const SPEC_IDS = { + 'Warrior': { Arms: 71, Fury: 72, Protection: 73 }, + 'Paladin': { Holy: 65, Protection: 66, Retribution: 70 }, + 'Hunter': { 'Beast Mastery': 253, Marksmanship: 254, Survival: 255 }, + 'Rogue': { Assassination: 259, Outlaw: 260, Subtlety: 261 }, + 'Priest': { Discipline: 256, Holy: 257, Shadow: 258 }, + 'Death Knight': { Blood: 250, Frost: 251, Unholy: 252 }, + 'Shaman': { Elemental: 262, Enhancement: 263, Restoration: 264 }, + 'Mage': { Arcane: 62, Fire: 63, Frost: 64 }, + 'Warlock': { Affliction: 265, Demonology: 266, Destruction: 267 }, + 'Monk': { Brewmaster: 268, Mistweaver: 270, Windwalker: 269 }, + 'Druid': { Balance: 102, Feral: 103, Guardian: 104, Restoration: 105 }, + 'Demon Hunter': { Havoc: 577, Vengeance: 581 }, + 'Evoker': { Devastation: 1467, Preservation: 1468, Augmentation: 1473 } +}; + +const CLASS_IDS = { + 1: 'Warrior', 2: 'Paladin', 3: 'Hunter', 4: 'Rogue', 5: 'Priest', + 6: 'Death Knight', 7: 'Shaman', 8: 'Mage', 9: 'Warlock', 10: 'Monk', + 11: 'Druid', 12: 'Demon Hunter', 13: 'Evoker' +}; + +const invTypeToSlot = { + INVTYPE_HEAD: 1, + INVTYPE_NECK: 2, + INVTYPE_SHOULDER: 3, + INVTYPE_SHIRT: 4, + INVTYPE_CHEST: 5, + INVTYPE_WAIST: 6, + INVTYPE_LEGS: 7, + INVTYPE_FEET: 8, + INVTYPE_WRIST: 9, + INVTYPE_HANDS: 10, + INVTYPE_FINGER: 11, + INVTYPE_TRINKET: 12, + INVTYPE_WEAPON: 13, + INVTYPE_SHIELD: 14, + INVTYPE_RANGED: 15, + INVTYPE_CLOAK: 16, + INVTYPE_2HWEAPON: 17, + INVTYPE_BAG: 18, + INVTYPE_TABARD: 19, + INVTYPE_ROBE: 20, + INVTYPE_WEAPONMAINHAND: 21, + INVTYPE_WEAPONOFFHAND: 22, + INVTYPE_HOLDABLE: 23 +}; + +function parseEquipment(equipment) { + const items = []; + for (const it of equipment.equipped_items || []) { + const slot = invTypeToSlot[it.inventory_type?.type] ?? null; + const transmogId = + it.transmog_item?.id ?? + it.transmog?.item?.id ?? + it.transmog?.id ?? + null; + items.push({ + slot, + slot_name: it.slot?.type || null, + item_id: it.item?.id ?? null, + name: it.name, + item_level: it.level?.value ?? null, + quality: it.quality?.type ? qualityRank(it.quality.type) : null, + icon: it.display?.display_string || null, + transmog_id: transmogId + }); + } + return items; +} + +function qualityRank(type) { + const ranks = { POOR: 0, COMMON: 1, UNCOMMON: 2, RARE: 3, EPIC: 4, LEGENDARY: 5, ARTIFACT: 6 }; + return ranks[type] ?? null; +} + +function parseSpecializations(data) { + const out = { active_spec: null, active_spec_id: null, specs: [] }; + if (data.active_specialization?.name) out.active_spec = data.active_specialization.name; + out.active_spec_id = data.active_specialization?.id ?? null; + for (const s of data.specializations || []) { + const spec = s.specialization?.name || null; + if (!spec) continue; + const entry = { spec, spec_id: s.specialization?.id ?? null, talents: null, loadout_text: null, class_tree_id: null, spec_tree_id: null }; + const lo = (s.loadouts || []).find((l) => l.is_active) || (s.loadouts || [])[0] || {}; + if (lo.selected_class_talents || lo.selected_spec_talents) { + entry.talents = [ + ...(lo.selected_class_talents || []).map((t) => ({ id: t.id ?? t.talent?.id ?? null, rank: t.rank ?? 1, tree: 'class' })), + ...(lo.selected_spec_talents || []).map((t) => ({ id: t.id ?? t.talent?.id ?? null, rank: t.rank ?? 1, tree: 'spec' })) + ].filter((t) => t.id != null); + entry.loadout_text = lo.talent_loadout_code || null; + } + entry.class_tree_id = extractTreeId(lo.selected_class_talent_tree?.key?.href); + entry.spec_tree_id = extractTreeId(lo.selected_spec_talent_tree?.key?.href); + out.specs.push(entry); + } + return out; +} + +function extractTreeId(href) { + const m = /\/talent-tree\/(\d+)/.exec(href || ''); + return m ? Number(m[1]) : null; +} + +export function tierForRating(rating) { + if (rating >= 2400) return 'Gladiateur'; + if (rating >= 2100) return 'Élite'; + if (rating >= 1800) return 'Duelliste'; + if (rating >= 1600) return 'Rival'; + if (rating >= 1400) return 'Combattant'; + return 'Challenger'; +} + +function parsePvp(pvp) { + const out = {}; + for (const b of pvp.pvp_bracket_statistics || []) { + const type = b.bracket?.type; + if (!type) continue; + const season = b.season_match_statistics || {}; + const weekly = b.weekly_match_statistics || {}; + const rating = b.rating || 0; + out[type] = { + rating, + tier: rating > 0 ? tierForRating(rating) : null, + won: season.won || 0, + lost: season.lost || 0, + weekly_won: weekly.won || 0, + weekly_lost: weekly.lost || 0 + }; + } + return out; +} + +export async function fetchCharacterProfile(char) { + if (!isConfigured()) { + const err = new Error('API Blizzard non configurée'); + err.code = 'NOT_CONFIGURED'; + throw err; + } + const region = char.region || 'eu'; + const realm = char.realm_slug || slugifyRealm(char.realm); + const name = encodeURIComponent(String(char.name || '').toLowerCase()); + const base = `/profile/wow/character/${realm}/${name}`; + + const [profile, pvp, titles, achievements, equipment, appearance, media, specializations] = await Promise.allSettled([ + api(region, base), + api(region, `${base}/pvp-summary`), + api(region, `${base}/titles`), + api(region, `${base}/achievements`), + api(region, `${base}/equipment`), + api(region, `${base}/appearance`), + api(region, `${base}/media`), + api(region, `${base}/specializations`) + ]); + + const profileData = profile.status === 'fulfilled' ? profile.value : null; + if (!profileData) throw new Error('Profil Blizzard introuvable'); + + const result = { + name: profileData.name, + level: profileData.level, + faction: profileData.faction?.name || null, + race: profileData.race?.name || null, + race_id: profileData.race?.id ?? null, + class: CLASS_IDS[profileData.character_class?.id] || profileData.character_class?.name || null, + gender: profileData.gender?.type === 'FEMALE' ? 1 : 0, + achievement_points: profileData.achievement_points, + title: profileData.title?.name || null, + media: media.status === 'fulfilled' ? { + avatar: media.value.avatar_url || null, + inset: media.value.inset_url || null, + main: media.value.main_raw_url || media.value.render_url || null + } : {} + }; + + if (pvp.status === 'fulfilled') result.pvp = parsePvp(pvp.value); + if (titles.status === 'fulfilled') { + result.titles = (titles.value.titles || []).map((t) => t.name).filter(Boolean); + result.active_title = titles.value.active_title?.name || null; + } + if (achievements.status === 'fulfilled') { + result.achievements = achievements.value; + result.achievement_count = achievements.value?.total_quantity ?? null; + } + if (equipment.status === 'fulfilled') result.equipment = parseEquipment(equipment.value); + if (specializations.status === 'fulfilled') result.specializations = parseSpecializations(specializations.value); + if (appearance.status === 'fulfilled') { + const a = appearance.value; + result.appearance = { + face: a.customization?.face ?? null, + skin_color: a.customization?.skin_color ?? null, + hair_variation: a.customization?.hair_variation ?? null, + hair_color: a.customization?.hair_color ?? null, + facial_hair: a.customization?.facial_hair ?? null, + horn_style: a.customization?.horn_style ?? null, + ...(a.customization_options ? { customization_options: a.customization_options } : {}) + }; + } + + return result; +} diff --git a/backend/src/services/fetcher.js b/backend/src/services/fetcher.js new file mode 100644 index 0000000..fc0c967 --- /dev/null +++ b/backend/src/services/fetcher.js @@ -0,0 +1,92 @@ +import { db, logFetch, getSetting, setSetting } from '../db.js'; +import { fetchCharacterProfile as fetchRaiderIo } from './raiderio.js'; +import { fetchCharacterProfile as fetchBlizzard, isConfigured as blizzardConfigured } from './blizzard.js'; +import { buildModel3d } from './wago.js'; +import { recomputeGoals } from './goals.js'; + +export async function refreshCharacter(charId) { + const char = db.prepare('SELECT * FROM characters WHERE id = ?').get(charId); + if (!char) return null; + + const merged = { + raiderio: null, + blizzard: null, + model3d: { items: [] } + }; + + try { + merged.raiderio = await fetchRaiderIo(char); + logFetch(charId, 'raiderio', 'ok', {}); + } catch (e) { + logFetch(charId, 'raiderio', 'error', { error: e.message, code: e.code }); + } + + if (blizzardConfigured()) { + try { + merged.blizzard = await fetchBlizzard(char); + logFetch(charId, 'blizzard', 'ok', {}); + } catch (e) { + logFetch(charId, 'blizzard', 'error', { error: e.message, code: e.code }); + } + } + + const equipment = merged.blizzard?.equipment || merged.raiderio?.gear || []; + try { + merged.model3d = await buildModel3d(equipment); + logFetch(charId, 'model3d', 'ok', { items: merged.model3d.items.length }); + } catch (e) { + merged.model3d = { items: [] }; + logFetch(charId, 'model3d', 'error', { error: e.message }); + } + + snapshotPvpHistory(charId, merged.blizzard?.pvp); + + const summary = merged.blizzard || merged.raiderio || {}; + const raider = merged.raiderio || {}; + const blizz = merged.blizzard || {}; + + db.prepare(`UPDATE characters SET + class = ?, spec = ?, level = ?, faction = ?, race = ?, race_id = ?, gender = ?, + data = ?, last_updated = datetime('now') + WHERE id = ?`).run( + summary.class || char.class || null, + summary.spec || char.spec || null, + summary.level ?? char.level ?? null, + summary.faction || char.faction || null, + summary.race || char.race || null, + blizz.race_id ?? char.race_id ?? null, + blizz.gender ?? raider.gender ?? char.gender ?? null, + JSON.stringify(merged), + charId + ); + + return db.prepare('SELECT * FROM characters WHERE id = ?').get(charId); +} + +export async function refreshAll() { + const chars = db.prepare('SELECT id FROM characters WHERE active = 1').all(); + for (const c of chars) { + try { + await refreshCharacter(c.id); + } catch (e) { + logFetch(c.id, 'refresh', 'error', { error: e.message }); + } + } + setSetting('last_fetch_at', new Date().toISOString()); + recomputeGoals(); + return chars.length; +} + +export async function refreshOne(charId) { + await refreshCharacter(charId); + recomputeGoals(); + return db.prepare('SELECT * FROM characters WHERE id = ?').get(charId); +} + +function snapshotPvpHistory(charId, pvp) { + if (!pvp || typeof pvp !== 'object') return; + const insert = db.prepare('INSERT INTO pvp_history (character_id, bracket, rating, tier, won, lost) VALUES (?, ?, ?, ?, ?, ?)'); + for (const [bracket, b] of Object.entries(pvp)) { + if (b && b.rating > 0) insert.run(charId, bracket, b.rating, b.tier || null, b.won || 0, b.lost || 0); + } +} diff --git a/backend/src/services/goals.js b/backend/src/services/goals.js new file mode 100644 index 0000000..8a06aa4 --- /dev/null +++ b/backend/src/services/goals.js @@ -0,0 +1,180 @@ +import { db } from '../db.js'; + +const ALL_CLASSES = [ + 'Death Knight', 'Demon Hunter', 'Druid', 'Evoker', 'Hunter', 'Mage', 'Monk', + 'Paladin', 'Priest', 'Rogue', 'Shaman', 'Warlock', 'Warrior' +]; + +const BRACKET_KEYS = { + blitz_2800: 'BLITZ', + shuffle_2300: 'SOLO_SHUFFLE', + arena2v2_2300: 'ARENA_2v2', + arena3v3_2300: 'ARENA_3v3' +}; + +function getCharacters() { + return db.prepare('SELECT * FROM characters WHERE active = 1').all().map((c) => { + try { + return { ...c, data: JSON.parse(c.data || '{}') }; + } catch { + return { ...c, data: {} }; + } + }); +} + +function bestPvpRating(chars, bracket) { + let best = { rating: 0, char: null }; + for (const c of chars) { + const rating = c.data?.blizzard?.pvp?.[bracket]?.rating; + if (typeof rating === 'number' && rating > best.rating) best = { rating, char: c.name }; + } + return best; +} + +function bestAchievementPoints(chars) { + let best = { points: 0, char: null }; + for (const c of chars) { + const p = c.data?.blizzard?.achievement_points ?? c.data?.raiderio?.achievement_points; + if (typeof p === 'number' && p > best.points) best = { points: p, char: c.name }; + } + return best; +} + +function bestMythicPlus(chars) { + let best = { score: 0, level: 0, char: null, dungeon: null }; + let hasData = false; + for (const c of chars) { + const ri = c.data?.raiderio; + if (!ri) continue; + hasData = true; + const score = ri.mythic_plus_scores?.all || 0; + const runs = ri.mythic_plus_best_runs || []; + const bestRun = runs.reduce((a, b) => (b.level > a.level ? b : a), { level: 0 }); + if (score > best.score) { + best = { score, level: bestRun.level, char: c.name, dungeon: bestRun.dungeon || null }; + } + } + return { ...best, hasData }; +} + +function mythicRaidProgress(chars) { + const raids = {}; + for (const c of chars) { + for (const [key, raid] of Object.entries(c.data?.raiderio?.raid_progression || {})) { + if (!raids[key]) raids[key] = { name: raid.name, killed: 0, total: 0 }; + const m = raid.mythic; + if (m && m.total > 0) { + raids[key].name = raid.name; + raids[key].total = Math.max(raids[key].total, m.total); + raids[key].killed = Math.max(raids[key].killed, m.killed); + } + } + } + const cleared = Object.values(raids).filter((r) => r.total > 0 && r.killed >= r.total).length; + const total = Object.values(raids).filter((r) => r.total > 0).length; + return { raids, cleared, total }; +} + +function classLevelProgress(chars, target) { + const perClass = {}; + let hasData = false; + for (const c of chars) { + const cls = c.data?.blizzard?.class || c.data?.raiderio?.class || c.class; + const level = c.data?.blizzard?.level ?? c.data?.raiderio?.level ?? c.level ?? null; + if (!cls) continue; + if (typeof level === 'number') hasData = true; + if (typeof level === 'number' && (perClass[cls] === undefined || level > perClass[cls])) { + perClass[cls] = level; + } + } + const covered = ALL_CLASSES.filter((cls) => { + const lvl = perClass[cls]; + return typeof lvl === 'number' && (target ? lvl >= target : lvl > 0); + }); + return { perClass, covered, total: ALL_CLASSES.length, list: ALL_CLASSES, hasData }; +} + +function gladiatorTitles(chars) { + const found = []; + for (const c of chars) { + for (const t of c.data?.blizzard?.titles || []) { + if (typeof t === 'string' && /gladiator/i.test(t)) { + found.push({ char: c.name, title: t }); + } + } + } + return found; +} + +export function recomputeGoals() { + const chars = getCharacters(); + const goals = db.prepare('SELECT * FROM goals_config WHERE enabled = 1 ORDER BY sort_order').all(); + const upsert = db.prepare(`INSERT INTO goals_state (key, label, current, progress, achieved, status, detail, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET label=excluded.label, current=excluded.current, progress=excluded.progress, + achieved=excluded.achieved, status=excluded.status, detail=excluded.detail, updated_at=excluded.updated_at`); + + const blizzardConfigured = chars.some((c) => c.data?.blizzard); + + function save(key, goal, current, progress, achieved, status, detail) { + upsert.run(key, goal.title, JSON.stringify(current), progress, achieved ? 1 : 0, status, JSON.stringify(detail || {})); + } + + for (const goal of goals) { + const key = goal.key; + const target = goal.target; + + if (key === 'gladiator_title') { + const glads = gladiatorTitles(chars); + const achieved = glads.length > 0; + save(key, goal, { found: glads, total: glads.length }, achieved ? 1 : 0, achieved, blizzardConfigured ? 'ok' : 'pending_blizzard', { glads }); + } + + else if (key === 'mythic_plus_max') { + const best = bestMythicPlus(chars); + const hasData = best.hasData; + save(key, goal, best, hasData ? Math.min(1, best.score / (goal.display_max || 10000)) : 0, false, hasData ? 'ok' : 'no_data', {}); + } + + else if (key === 'all_classes_90') { + const prog = classLevelProgress(chars, target); + const achieved = prog.covered.length >= prog.total; + const status = prog.hasData ? 'mixed' : (blizzardConfigured ? 'no_data' : 'pending_blizzard'); + save(key, goal, { covered: prog.covered, total: prog.total, perClass: prog.perClass }, prog.covered / prog.total, achieved, status, {}); + } + + else if (BRACKET_KEYS[key]) { + const bracket = BRACKET_KEYS[key]; + const best = bestPvpRating(chars, bracket); + const hasData = best.rating > 0; + const achieved = hasData && target !== null && best.rating >= target; + const progress = target ? Math.min(1, best.rating / target) : (hasData ? Math.min(1, best.rating / (goal.display_max || 3000)) : 0); + save(key, goal, best, progress, achieved, hasData ? 'ok' : (blizzardConfigured ? 'no_data' : 'pending_blizzard'), {}); + } + + else if (key === 'achievements') { + const best = bestAchievementPoints(chars); + const hasData = best.points > 0; + save(key, goal, best, hasData ? Math.min(1, best.points / (goal.display_max || 50000)) : 0, false, hasData ? 'ok' : (blizzardConfigured ? 'no_data' : 'pending_blizzard'), {}); + } + + else if (key === 'mythic_raids') { + const prog = mythicRaidProgress(chars); + const total = prog.total; + const achieved = total > 0 && prog.cleared >= total; + const progress = total > 0 ? prog.cleared / total : 0; + save(key, goal, { cleared: prog.cleared, total, raids: prog.raids }, progress, achieved, total > 0 ? 'ok' : 'no_data', {}); + } + + else if (key === 'all_sets') { + const counts = chars.map((c) => ({ char: c.name, sets: c.data?.blizzard?.collections?.sets ?? null })); + const known = counts.filter((x) => typeof x.sets === 'number'); + const totalSets = known.reduce((a, b) => a + b.sets, 0); + const hasData = known.length > 0; + const displayMax = goal.display_max || 1000; + save(key, goal, { characters: counts, collected: totalSets }, hasData ? Math.min(1, totalSets / displayMax) : 0, false, hasData ? 'ok' : 'pending_blizzard', {}); + } + } + + db.prepare(`DELETE FROM goals_state WHERE key NOT IN (SELECT key FROM goals_config WHERE enabled = 1)`).run(); +} diff --git a/backend/src/services/modelviewerProxy.js b/backend/src/services/modelviewerProxy.js new file mode 100644 index 0000000..58479c9 --- /dev/null +++ b/backend/src/services/modelviewerProxy.js @@ -0,0 +1,22 @@ +const CONTENT_BASE = 'https://wow.zamimg.com/modelviewer/live/'; + +const UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36'; + +export async function proxyModelViewer(req, res) { + const rel = req.params[0]; + if (!rel || rel.includes('..')) return res.status(400).json({ error: 'Bad path' }); + + const upstream = await fetch(CONTENT_BASE + rel, { + headers: { 'User-Agent': UA, Accept: '*/*' } + }); + if (!upstream.ok) return res.status(upstream.status).send('Upstream error'); + + const contentType = upstream.headers.get('content-type') || 'application/octet-stream'; + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.setHeader('Access-Control-Allow-Origin', '*'); + + const buf = Buffer.from(await upstream.arrayBuffer()); + res.send(buf); +} diff --git a/backend/src/services/raiderio.js b/backend/src/services/raiderio.js new file mode 100644 index 0000000..ba21c7a --- /dev/null +++ b/backend/src/services/raiderio.js @@ -0,0 +1,122 @@ +const BASE = 'https://raider.io/api/v1/characters/profile'; + +export function slugifyRealm(realm) { + return realm + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9\s-]+/g, '') + .trim() + .replace(/\s+/g, '-'); +} + +const raiderSlotToInventoryType = { + head: 1, + neck: 2, + shoulder: 3, + shirt: 4, + chest: 5, + waist: 6, + legs: 7, + feet: 8, + wrist: 9, + hands: 10, + finger1: 11, + finger2: 11, + trinket1: 12, + trinket2: 12, + back: 16, + tabard: 19, + mainhand: 21, + offhand: 22 +}; + +const RACE_IDS = { + 'Human': 1, 'Orc': 2, 'Dwarf': 3, 'Night Elf': 4, 'Undead': 5, 'Tauren': 6, + 'Gnome': 7, 'Troll': 8, 'Goblin': 9, 'Blood Elf': 10, 'Draenei': 11, 'Worgen': 22, + 'Pandaren': 24, 'Nightborne': 27, 'Highmountain Tauren': 28, 'Void Elf': 29, + 'Lightforged Draenei': 30, 'Zandalari Troll': 31, 'Kul Tiran': 32, + 'Dark Iron Dwarf': 34, 'Vulpera': 35, "Mag'har Orc": 36, 'Mechagnome': 37, + 'Dracthyr': 52, 'Earthen': 80 +}; + +export async function fetchCharacterProfile(char) { + const params = new URLSearchParams({ + region: char.region, + realm: slugifyRealm(char.realm), + name: char.name, + fields: 'gear,mythic_plus_scores,mythic_plus_best_runs,raid_progression' + }); + const url = `${BASE}?${params.toString()}`; + const res = await fetch(url, { headers: { 'User-Agent': 'wycz-app/1.0' } }); + if (res.status === 404) { + const err = new Error('Personnage introuvable sur Raider.io (pas de donnée M+/raid)'); + err.code = 'NOT_FOUND'; + throw err; + } + if (!res.ok) { + const err = new Error(`Raider.io HTTP ${res.status}`); + err.code = 'HTTP_ERROR'; + throw err; + } + const data = await res.json(); + return transform(data); +} + +function transform(d) { + const gear = d.gear || {}; + const mps = d.mythic_plus_scores || {}; + const bestRuns = (d.mythic_plus_best_runs || []).map((r) => ({ + dungeon: r.dungeon, + level: r.mythic_level, + score: r.score, + cleared: r.clear_time_ms + })); + const raidProgression = {}; + for (const [key, raid] of Object.entries(d.raid_progression || {})) { + raidProgression[key] = { + name: raid.name, + mythic: raid.mythic ? { killed: raid.mythic.bosses_killed, total: raid.mythic.total_bosses } : null, + heroic: raid.heroic ? { killed: raid.heroic.bosses_killed, total: raid.heroic.total_bosses } : null + }; + } + const items = []; + for (const [slotName, it] of Object.entries(gear.items || {})) { + if (!it || !it.item_id) continue; + items.push({ + slot: raiderSlotToInventoryType[slotName] ?? null, + slot_name: slotName, + item_id: it.item_id, + item_level: it.item_level, + name: it.name, + quality: it.item_quality, + icon: it.icon + }); + } + return { + name: d.name, + realm: d.realm, + region: d.region, + class: d.class, + spec: d.active_spec_name, + role: d.active_spec_role, + race: d.race, + race_id: RACE_IDS[d.race] || null, + faction: d.faction, + gender: d.gender === 'female' ? 1 : 0, + level: d.level || null, + achievement_points: d.achievement_points, + thumbnail_url: d.thumbnail_url, + profile_url: d.profile_url, + ilvl: gear.item_level_equipped || 0, + mythic_plus_scores: { + all: mps.all || 0, + dps: mps.dps || 0, + healer: mps.healer || 0, + tank: mps.tank || 0 + }, + mythic_plus_best_runs: bestRuns, + raid_progression: raidProgression, + gear: items + }; +} diff --git a/backend/src/services/wago.js b/backend/src/services/wago.js new file mode 100644 index 0000000..f4c5904 --- /dev/null +++ b/backend/src/services/wago.js @@ -0,0 +1,88 @@ +import { db, getSetting } from '../db.js'; +import { config } from '../config.js'; + +const ARMOR_SLOTS = new Set([1, 3, 4, 5, 6, 7, 8, 9, 10, 16, 19, 20]); + +export function isVisibleSlot(slot) { + if (!slot) return false; + if (slot === 2 || slot === 11 || slot === 12) return false; + return true; +} + +async function wagoFind(table, field, value) { + const build = getSetting('wow_tools_build') || config.wowToolsBuild; + const params = new URLSearchParams(); + params.append('build', build); + params.append(`filter[${field}]`, `exact:${value}`); + const url = `https://wago.tools/api/db2-find/${table}?${params.toString()}`; + const res = await fetch(url, { headers: { 'User-Agent': 'wycz-app/1.0' } }); + if (!res.ok) throw new Error(`wago.tools HTTP ${res.status}`); + const data = await res.json(); + return data?.data || []; +} + +export async function resolveItem(itemId) { + const cached = db.prepare('SELECT display_id, display_type FROM item_cache WHERE item_id = ?').get(itemId); + if (cached && cached.display_id) return { item_id: itemId, display_id: cached.display_id, display_type: cached.display_type }; + + let displayId = null; + let displayType = null; + try { + const ima = await wagoFind('ItemModifiedAppearance', 'ItemID', itemId); + if (ima.length) { + const ia = await wagoFind('ItemAppearance', 'ID', ima[0].ItemAppearanceID); + if (ia.length) { + displayId = ia[0].ItemDisplayInfoID ?? null; + displayType = ia[0].DisplayType ?? null; + } + } + } catch { + displayId = null; + } + + if (displayId) { + db.prepare('INSERT INTO item_cache (item_id, display_id, display_type) VALUES (?, ?, ?)') + .run(itemId, displayId, displayType); + } + return { item_id: itemId, display_id: displayId, display_type: displayType }; +} + +export async function resolveItems(itemIds) { + const results = []; + for (const id of itemIds) { + if (!id) continue; + results.push(await resolveItem(id)); + } + return results; +} + +export async function buildModel3d(equipmentItems) { + const model = { items: [] }; + const toResolve = []; + const displayed = []; + + for (const it of equipmentItems || []) { + if (!isVisibleSlot(it.slot) || !it.item_id) continue; + const effectiveId = it.transmog_id || it.item_id; + displayed.push({ slot: it.slot, effectiveId }); + toResolve.push(effectiveId); + } + + const uniqueIds = [...new Set(toResolve)]; + const resolved = await resolveItems(uniqueIds); + const map = new Map(resolved.map((r) => [r.item_id, r])); + + for (const d of displayed) { + const r = map.get(d.effectiveId); + if (r && r.display_id) { + model.items.push([d.slot, r.display_id]); + } + } + return model; +} + +export function armorSlotForDisplayType(displayType) { + if (displayType === null) return null; + if (ARMOR_SLOTS.has(displayType)) return displayType; + return null; +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..a39d55a --- /dev/null +++ b/deploy.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SERVER="debian@51.195.5.147" +REMOTE_DIR="/home/debian/wycz" +LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "==> Synchronisation des fichiers vers le serveur" +rsync -az --delete \ + --exclude node_modules --exclude data --exclude .env \ + "$LOCAL_DIR/" "$SERVER:$REMOTE_DIR/" + +echo "==> (Re)construction du conteneur Docker" +ssh "$SERVER" "cd $REMOTE_DIR && docker compose up -d --build" + +echo "==> Vérification" +ssh "$SERVER" "sleep 2 && docker ps --filter name=wycz --format '{{.Names}} {{.Status}} {{.Ports}}' && curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://localhost:3004/" + +echo "==> Logs récents" +ssh "$SERVER" "docker logs --tail 20 wycz" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b6f0522 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + wycz: + build: ./backend + restart: unless-stopped + container_name: wycz + environment: + PORT: 3004 + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-now} + SESSION_SECRET: ${SESSION_SECRET:-change-me-session-secret} + DATA_DIR: /data + volumes: + - wycz_data:/data + ports: + - "3004:3004" + +volumes: + wycz_data: