From 9c9fb3a2ca9675d1d67f83651023e344094f8be1 Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Wed, 12 Aug 2026 16:03:51 -0400 Subject: [PATCH] feat: shot-crafter-calculator with H2 persistence and production history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quarkus 3.20.1 monolith serving React 18 + TypeScript + Tailwind SPA. Features: - Three-tab calculator (Insumos, Fórmulas, Calculadora) for Soulshot, Spiritshot and Blessed Spiritshot crafting in Lineage 2 Interlude/Clásico with all 15 grades and pre-loaded recipes - Real-time profitability computation (cristales → ore → crafteos → shots → cost → sale → ganancia) - Multi-user auth with JWT in httpOnly cookie (bcrypt + RSA 2048) - H2 file-based persistence in ./data/shots.mv.db (file-based, H2) - Auto-save on state changes (debounced 500ms) - Production history with stats (total/avg/best/worst/last5avg) and per-run detail modal with snapshot of insumos+formulas Stack: - Backend: Quarkus REST + Hibernate ORM Panache + smallrye-jwt - Frontend: React 18 + TypeScript + Vite + Tailwind 3 - Build: Maven runs frontend-maven-plugin (Node 22 + npm ci) then copies dist to META-INF/resources for Quarkus to serve Verified: - 5 backend endpoints + 5 history endpoints with curl - 35/35 browser tests via Playwright + Chromium - All TS strict, all builds green --- .gitignore | 14 + pom.xml | 213 ++ src/frontend/index.html | 13 + src/frontend/package-lock.json | 2698 +++++++++++++++++ src/frontend/package.json | 25 + src/frontend/postcss.config.js | 6 + src/frontend/public/favicon.svg | 11 + src/frontend/src/App.tsx | 130 + src/frontend/src/api/client.ts | 115 + src/frontend/src/api/history.ts | 137 + src/frontend/src/auth/AuthContext.tsx | 64 + src/frontend/src/auth/LoginPage.tsx | 128 + .../src/components/CalculadoraSection.tsx | 188 ++ .../src/components/FormulasSection.tsx | 111 + .../src/components/HistorySection.tsx | 44 + .../src/components/InsumosSection.tsx | 140 + .../src/components/RunDetailsModal.tsx | 324 ++ src/frontend/src/components/RunsTable.tsx | 94 + src/frontend/src/components/SaveIndicator.tsx | 34 + src/frontend/src/components/ShotTable.tsx | 123 + src/frontend/src/components/StatsCards.tsx | 80 + src/frontend/src/components/TabBar.tsx | 42 + src/frontend/src/components/TotalsSummary.tsx | 77 + src/frontend/src/hooks/useHistory.ts | 70 + src/frontend/src/hooks/usePersistedState.ts | 94 + src/frontend/src/index.css | 34 + src/frontend/src/main.tsx | 10 + src/frontend/src/types.ts | 53 + src/frontend/src/utils/calc.ts | 83 + src/frontend/src/utils/format.ts | 16 + src/frontend/tailwind.config.js | 26 + src/frontend/tsconfig.app.json | 21 + src/frontend/tsconfig.json | 7 + src/frontend/tsconfig.node.json | 18 + src/frontend/vite.config.ts | 17 + .../com/l2/shots/auth/AuthMeResponse.java | 18 + .../java/com/l2/shots/auth/AuthResource.java | 123 + .../java/com/l2/shots/auth/AuthService.java | 75 + .../java/com/l2/shots/auth/Credentials.java | 6 + .../java/com/l2/shots/auth/JwtCookieAuth.java | 45 + src/main/java/com/l2/shots/auth/User.java | 35 + .../java/com/l2/shots/auth/UserState.java | 29 + .../com/l2/shots/history/HistoryResource.java | 120 + .../com/l2/shots/history/HistoryService.java | 127 + .../com/l2/shots/history/HistoryStats.java | 16 + .../com/l2/shots/history/ProductionRun.java | 54 + .../java/com/l2/shots/history/RunDetails.java | 16 + src/main/java/com/l2/shots/history/RunIn.java | 15 + .../java/com/l2/shots/history/RunItem.java | 31 + .../com/l2/shots/history/RunSnapshot.java | 16 + .../java/com/l2/shots/history/RunSummary.java | 30 + .../java/com/l2/shots/state/AppState.java | 59 + .../com/l2/shots/state/StateResource.java | 74 + .../java/com/l2/shots/state/StateService.java | 48 + src/main/resources/application.properties | 26 + src/main/resources/privateKey.pem | 28 + src/main/resources/publicKey.pem | 9 + 57 files changed, 6260 insertions(+) create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 src/frontend/index.html create mode 100644 src/frontend/package-lock.json create mode 100644 src/frontend/package.json create mode 100644 src/frontend/postcss.config.js create mode 100644 src/frontend/public/favicon.svg create mode 100644 src/frontend/src/App.tsx create mode 100644 src/frontend/src/api/client.ts create mode 100644 src/frontend/src/api/history.ts create mode 100644 src/frontend/src/auth/AuthContext.tsx create mode 100644 src/frontend/src/auth/LoginPage.tsx create mode 100644 src/frontend/src/components/CalculadoraSection.tsx create mode 100644 src/frontend/src/components/FormulasSection.tsx create mode 100644 src/frontend/src/components/HistorySection.tsx create mode 100644 src/frontend/src/components/InsumosSection.tsx create mode 100644 src/frontend/src/components/RunDetailsModal.tsx create mode 100644 src/frontend/src/components/RunsTable.tsx create mode 100644 src/frontend/src/components/SaveIndicator.tsx create mode 100644 src/frontend/src/components/ShotTable.tsx create mode 100644 src/frontend/src/components/StatsCards.tsx create mode 100644 src/frontend/src/components/TabBar.tsx create mode 100644 src/frontend/src/components/TotalsSummary.tsx create mode 100644 src/frontend/src/hooks/useHistory.ts create mode 100644 src/frontend/src/hooks/usePersistedState.ts create mode 100644 src/frontend/src/index.css create mode 100644 src/frontend/src/main.tsx create mode 100644 src/frontend/src/types.ts create mode 100644 src/frontend/src/utils/calc.ts create mode 100644 src/frontend/src/utils/format.ts create mode 100644 src/frontend/tailwind.config.js create mode 100644 src/frontend/tsconfig.app.json create mode 100644 src/frontend/tsconfig.json create mode 100644 src/frontend/tsconfig.node.json create mode 100644 src/frontend/vite.config.ts create mode 100644 src/main/java/com/l2/shots/auth/AuthMeResponse.java create mode 100644 src/main/java/com/l2/shots/auth/AuthResource.java create mode 100644 src/main/java/com/l2/shots/auth/AuthService.java create mode 100644 src/main/java/com/l2/shots/auth/Credentials.java create mode 100644 src/main/java/com/l2/shots/auth/JwtCookieAuth.java create mode 100644 src/main/java/com/l2/shots/auth/User.java create mode 100644 src/main/java/com/l2/shots/auth/UserState.java create mode 100644 src/main/java/com/l2/shots/history/HistoryResource.java create mode 100644 src/main/java/com/l2/shots/history/HistoryService.java create mode 100644 src/main/java/com/l2/shots/history/HistoryStats.java create mode 100644 src/main/java/com/l2/shots/history/ProductionRun.java create mode 100644 src/main/java/com/l2/shots/history/RunDetails.java create mode 100644 src/main/java/com/l2/shots/history/RunIn.java create mode 100644 src/main/java/com/l2/shots/history/RunItem.java create mode 100644 src/main/java/com/l2/shots/history/RunSnapshot.java create mode 100644 src/main/java/com/l2/shots/history/RunSummary.java create mode 100644 src/main/java/com/l2/shots/state/AppState.java create mode 100644 src/main/java/com/l2/shots/state/StateResource.java create mode 100644 src/main/java/com/l2/shots/state/StateService.java create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/privateKey.pem create mode 100644 src/main/resources/publicKey.pem diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ee7c7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +target/ +.mvn/ +node/ +node_modules/ +src/frontend/node_modules/ +src/frontend/dist/ +src/frontend/tsconfig.app.tsbuildinfo +src/frontend/tsconfig.node.tsbuildinfo +data/ +.idea/ +.vscode/ +*.iml +.DS_Store +*.log diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..1ee109c --- /dev/null +++ b/pom.xml @@ -0,0 +1,213 @@ + + + 4.0.0 + + com.l2.shots + shot-crafter-calculator + 1.0.0 + jar + + + UTF-8 + UTF-8 + 21 + + quarkus-bom + io.quarkus.platform + 3.20.1 + + 3.13.0 + 3.5.0 + 3.5.0 + + 1.15.0 + v22.11.0 + 10.9.0 + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-vertx-http + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-hibernate-orm-panache + + + io.quarkus + quarkus-jdbc-h2 + + + io.quarkus + quarkus-smallrye-jwt + + + io.quarkus + quarkus-smallrye-jwt-build + + + io.quarkus + quarkus-elytron-security-common + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + + com.github.eirslett + frontend-maven-plugin + ${frontend-maven-plugin.version} + + ${node.version} + ${npm.version} + src/frontend + target + + + + install-frontend-tools + initialize + + install-node-and-npm + + + + npm-install + generate-resources + + npm + + + ci + + + + npm-build + generate-resources + + npm + + + run build + + + + + + + maven-resources-plugin + 3.3.1 + + + copy-frontend-dist + process-resources + + copy-resources + + + ${project.build.directory}/classes/META-INF/resources/ + + + src/frontend/dist + false + + + + + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + true + + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + + + + + + + + + native + + + native + + + + true + + + + + maven-failsafe-plugin + ${failsafe-plugin.version} + + + + integration-test + verify + + + + + + ${project.build.directory}/${project.build.finalName}-runner + + + + + + + + diff --git a/src/frontend/index.html b/src/frontend/index.html new file mode 100644 index 0000000..ecfb4f8 --- /dev/null +++ b/src/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Calculadora de Craft de Shots — Lineage 2 + + +
+ + + diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json new file mode 100644 index 0000000..dc8eb51 --- /dev/null +++ b/src/frontend/package-lock.json @@ -0,0 +1,2698 @@ +{ + "name": "shot-crafter-calculator", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shot-crafter-calculator", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.405", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz", + "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==", + "dev": true, + "license": "ISC" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "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/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "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": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/src/frontend/package.json b/src/frontend/package.json new file mode 100644 index 0000000..95c9ab0 --- /dev/null +++ b/src/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "shot-crafter-calculator", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11" + } +} diff --git a/src/frontend/postcss.config.js b/src/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/src/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src/frontend/public/favicon.svg b/src/frontend/public/favicon.svg new file mode 100644 index 0000000..89cbf1a --- /dev/null +++ b/src/frontend/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx new file mode 100644 index 0000000..8a2d3cd --- /dev/null +++ b/src/frontend/src/App.tsx @@ -0,0 +1,130 @@ +import { useState } from 'react' +import { AuthProvider, useAuth } from './auth/AuthContext' +import { LoginPage } from './auth/LoginPage' +import { TabBar, type TabKey } from './components/TabBar' +import { InsumosSection } from './components/InsumosSection' +import { FormulasSection } from './components/FormulasSection' +import { CalculadoraSection } from './components/CalculadoraSection' +import { HistorySection } from './components/HistorySection' +import { SaveIndicator } from './components/SaveIndicator' +import { usePersistedState } from './hooks/usePersistedState' +import { makeDefaultAppState, makeEmptyAppState } from './data/defaults' + +export default function App() { + return ( + + + + ) +} + +function AppRouter() { + const { user, loading } = useAuth() + if (loading) return + if (!user) return + return +} + +function AuthenticatedApp() { + const { user, logout } = useAuth() + const { state, setState, status, errorMessage, reset } = usePersistedState() + const [tab, setTab] = useState('insumos') + + if (!state) return + + const handleResetExamples = () => { + setState(makeDefaultAppState()) + } + + const handleClearAll = () => { + setState(makeEmptyAppState()) + } + + const handleResetServer = async () => { + if (confirm('¿Borrar tu estado guardado en el servidor?')) { + await reset() + } + } + + return ( +
+
+
+
+

+ Calculadora de Craft de Shots +

+

+ {user?.username ?? ''} + {' · '} + Lineage 2 — Interlude / Clásico +

+
+
+ + + + + +
+
+ +
+ +
+ {tab === 'insumos' && ( + + )} + {tab === 'formulas' && ( + + )} + {tab === 'calculadora' && ( + + )} + {tab === 'historial' && } +
+ +
+

+ Auto-guardado activo · Cambios persistidos en el servidor cada ~500ms +

+
+
+ ) +} + +function LoadingScreen({ message }: { message: string }) { + return ( +
+
+
+

{message}

+
+
+ ) +} diff --git a/src/frontend/src/api/client.ts b/src/frontend/src/api/client.ts new file mode 100644 index 0000000..964b4e2 --- /dev/null +++ b/src/frontend/src/api/client.ts @@ -0,0 +1,115 @@ +export class ApiError extends Error { + constructor(public status: number, message: string) { + super(message) + this.name = 'ApiError' + } +} + +export interface User { + id: string + username: string + createdAt: string +} + +export interface AppState { + insumos: { + cristales: Record + soulOre: number + spiritOre: number + venta: Record> + } + formulas: Array<{ + id: string + tipo: string + grado: string + cristalesReq: number + soulOreReq: number | null + spiritOreReq: number | null + shotsObtenidos: number + }> + disponibles: Record> +} + +async function request(path: string, options: RequestInit = {}): Promise { + const res = await fetch(path, { + ...options, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + + if (res.status === 204) { + return undefined as T + } + + const text = await res.text() + let body: unknown = null + if (text) { + try { + body = JSON.parse(text) + } catch { + body = text + } + } + + if (!res.ok) { + const message = + body && typeof body === 'object' && 'error' in body + ? String((body as { error: string }).error) + : `HTTP ${res.status}` + throw new ApiError(res.status, message) + } + + return body as T +} + +export const api = { + async me(): Promise { + try { + return await request('/api/auth/me') + } catch (e) { + if (e instanceof ApiError && e.status === 401) return null + throw e + } + }, + + async login(username: string, password: string): Promise { + return request('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }) + }, + + async register(username: string, password: string): Promise { + return request('/api/auth/register', { + method: 'POST', + body: JSON.stringify({ username, password }), + }) + }, + + async logout(): Promise { + return request('/api/auth/logout', { method: 'POST' }) + }, + + async getState(): Promise { + try { + return await request('/api/state') + } catch (e) { + if (e instanceof ApiError && e.status === 404) return null + throw e + } + }, + + async putState(state: AppState): Promise { + return request('/api/state', { + method: 'PUT', + body: JSON.stringify(state), + }) + }, + + async resetState(): Promise { + return request('/api/state', { method: 'DELETE' }) + }, +} diff --git a/src/frontend/src/api/history.ts b/src/frontend/src/api/history.ts new file mode 100644 index 0000000..b878d52 --- /dev/null +++ b/src/frontend/src/api/history.ts @@ -0,0 +1,137 @@ +import { ApiError } from './client' + +async function request(path: string, options: RequestInit = {}): Promise { + const res = await fetch(path, { + ...options, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + + if (res.status === 204) { + return undefined as T + } + + const text = await res.text() + let body: unknown = null + if (text) { + try { + body = JSON.parse(text) + } catch { + body = text + } + } + + if (!res.ok) { + const message = + body && typeof body === 'object' && 'error' in body + ? String((body as { error: string }).error) + : `HTTP ${res.status}` + throw new ApiError(res.status, message) + } + + return body as T +} + +export interface RunItem { + tipo: string + grado: string + cristalesDisponibles: number + cristalesUsados: number + oreNecesario: number + crafteosPosibles: number + shotsObtenidos: number + costoTotal: number + valorVenta: number + ganancia: number +} + +export interface RunSnapshot { + insumos: unknown + formulas: unknown +} + +export interface RunSummary { + id: string + createdAt: string + label: string | null + totalCost: number + totalSale: number + totalProfit: number + totalShots: number + totalCristalesUsed: number + totalOreUsed: number +} + +export interface RunDetails extends RunSummary { + items: RunItem[] + snapshot: RunSnapshot +} + +export interface HistoryStats { + totalRuns: number + totalCost: number + totalSale: number + totalProfit: number + totalShots: number + avgProfit: number + avgCost: number + avgSale: number + bestRun: RunSummary | null + worstRun: RunSummary | null + last5Avg: number + last10Avg: number +} + +export interface RunIn { + label: string | null + totalCost: number + totalSale: number + totalProfit: number + totalShots: number + totalCristalesUsed: number + totalOreUsed: number + items: RunItem[] + snapshot: RunSnapshot +} + +export const historyApi = { + async saveRun(payload: RunIn): Promise { + return request('/api/history/runs', { + method: 'POST', + body: JSON.stringify(payload), + }) + }, + + async listRuns(): Promise { + return request('/api/history/runs') + }, + + async getRun(id: string): Promise { + try { + return await request(`/api/history/runs/${id}`) + } catch (e) { + if (e instanceof ApiError && e.status === 404) { + throw new Error('Producción no encontrada') + } + throw e + } + }, + + async deleteRun(id: string): Promise { + try { + return await request(`/api/history/runs/${id}`, { method: 'DELETE' }) + } catch (e) { + if (e instanceof ApiError && e.status === 404) { + throw new Error('Producción no encontrada') + } + throw e + } + }, + + async getStats(): Promise { + return request('/api/history/stats') + }, +} diff --git a/src/frontend/src/auth/AuthContext.tsx b/src/frontend/src/auth/AuthContext.tsx new file mode 100644 index 0000000..93b5cd5 --- /dev/null +++ b/src/frontend/src/auth/AuthContext.tsx @@ -0,0 +1,64 @@ +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react' +import { ApiError, api, type User } from '../api/client' + +interface AuthContextValue { + user: User | null + loading: boolean + login: (username: string, password: string) => Promise + register: (username: string, password: string) => Promise + logout: () => Promise +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + api + .me() + .then((u) => { + if (!cancelled) setUser(u) + }) + .catch((e) => { + if (!(e instanceof ApiError) || e.status !== 401) { + console.error('auth check failed', e) + } + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + const login = useCallback(async (username: string, password: string) => { + const u = await api.login(username, password) + setUser(u) + }, []) + + const register = useCallback(async (username: string, password: string) => { + const u = await api.register(username, password) + setUser(u) + }, []) + + const logout = useCallback(async () => { + await api.logout() + setUser(null) + }, []) + + return ( + + {children} + + ) +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth debe usarse dentro de ') + return ctx +} diff --git a/src/frontend/src/auth/LoginPage.tsx b/src/frontend/src/auth/LoginPage.tsx new file mode 100644 index 0000000..457d42c --- /dev/null +++ b/src/frontend/src/auth/LoginPage.tsx @@ -0,0 +1,128 @@ +import { useState, type FormEvent } from 'react' +import { useAuth } from './AuthContext' + +export function LoginPage() { + const { login, register } = useAuth() + const [mode, setMode] = useState<'login' | 'register'>('login') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + if (mode === 'login') { + await login(username, password) + } else { + await register(username, password) + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Error desconocido' + if (mode === 'login') { + setError('Usuario o contraseña incorrectos.') + } else if (msg.includes('no disponible')) { + setError('Ese username ya está en uso.') + } else if (msg.toLowerCase().includes('datos')) { + setError('Username (3-30 chars, alfanumérico o _) y password (>= 8 chars).') + } else { + setError(msg) + } + } finally { + setSubmitting(false) + } + } + + const toggleMode = () => { + setMode((m) => (m === 'login' ? 'register' : 'login')) + setError(null) + } + + return ( +
+
+
+
+

+ Calculadora de Craft de Shots +

+

+ Lineage 2 — Interlude / Clásico +

+
+ +
+
+ + setUsername(e.target.value)} + autoComplete="username" + autoFocus + required + minLength={3} + maxLength={30} + pattern="[a-zA-Z0-9_]{3,30}" + className="input-editable w-full" + /> +
+ +
+ + setPassword(e.target.value)} + autoComplete={mode === 'login' ? 'current-password' : 'new-password'} + required + minLength={8} + className="input-editable w-full" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+ +
+ +
+
+ +

+ Cada usuario tiene su propio estado guardado en el servidor. +

+
+
+ ) +} diff --git a/src/frontend/src/components/CalculadoraSection.tsx b/src/frontend/src/components/CalculadoraSection.tsx new file mode 100644 index 0000000..9401bfe --- /dev/null +++ b/src/frontend/src/components/CalculadoraSection.tsx @@ -0,0 +1,188 @@ +import { useMemo, useState } from 'react' +import { SHOT_TYPES, type CristalesDisponibles, type Formula, type Insumos } from '../types' +import { ShotTable } from './ShotTable' +import { TotalsSummary } from './TotalsSummary' +import { calcularFila, sumarTotales } from '../utils/calc' +import type { AppState } from '../api/client' +import { useHistory, type HistorySaveStatus } from '../hooks/useHistory' + +interface CalculadoraSectionProps { + state: AppState + onChange: (next: AppState | ((prev: AppState) => AppState)) => void +} + +export function CalculadoraSection({ state, onChange }: CalculadoraSectionProps) { + const insumos: Insumos = state.insumos + const formulas: Formula[] = state.formulas + const disponibles: CristalesDisponibles = state.disponibles + const { saveRun, saveStatus, saveError } = useHistory() + const [label, setLabel] = useState('') + + const handleDisponibles = (tipo: string, grado: string, value: number) => { + onChange((prev) => ({ + ...prev, + disponibles: { + ...prev.disponibles, + [tipo]: { ...prev.disponibles[tipo], [grado]: value }, + }, + })) + } + + const tablas = useMemo(() => { + return SHOT_TYPES.map((tipo) => { + const formulasTipo = formulas.filter((f) => f.tipo === tipo) + const calculos = formulasTipo.map((f) => + calcularFila(f, disponibles[tipo][f.grado], insumos), + ) + const totales = sumarTotales(calculos) + return { tipo, formulas: formulasTipo, calculos, totales } + }) + }, [insumos, formulas, disponibles]) + + const totalesGlobal = useMemo( + () => sumarTotales(tablas.flatMap((t) => t.calculos)), + [tablas], + ) + + const canSave = totalesGlobal.cristalesUsados > 0 && saveStatus !== 'saving' + + const handleSave = async () => { + if (!canSave) return + const items = tablas.flatMap((t) => + t.formulas.map((f, i) => { + const c = t.calculos[i] + return { + tipo: t.tipo, + grado: f.grado, + cristalesDisponibles: c.cristalesDisponibles, + cristalesUsados: c.cristalesUsados, + oreNecesario: c.oreNecesario, + crafteosPosibles: c.crafteosPosibles, + shotsObtenidos: c.shotsObtenidos, + costoTotal: c.costoTotal, + valorVenta: c.valorVenta, + ganancia: c.ganancia, + } + }), + ) + try { + await saveRun({ + label: label.trim() || null, + totalCost: totalesGlobal.costoTotal, + totalSale: totalesGlobal.valorVenta, + totalProfit: totalesGlobal.ganancia, + totalShots: totalesGlobal.shotsObtenidos, + totalCristalesUsed: totalesGlobal.cristalesUsados, + totalOreUsed: totalesGlobal.oreNecesario, + items, + snapshot: { + insumos: insumos as unknown, + formulas: formulas as unknown, + }, + }) + setLabel('') + } catch { + // error ya manejado en saveStatus/saveError + } + } + + return ( +
+
+

+ Ingresa solo los cristales disponibles por grado en cada tabla. La ore + necesaria, los crafteos, los shots producidos, el costo y la ganancia se calculan en + tiempo real a partir de los precios de la pestaña Insumos y las recetas de la + pestaña Fórmulas. +

+
+ +
+
+
+ + setLabel(e.target.value)} + maxLength={100} + placeholder="p.ej. Sesión lunes, crafteo nocturno…" + className="input-editable w-full text-left" + /> +
+ +
+ +
+ + {tablas.map((t) => ( + handleDisponibles(t.tipo, grado, value)} + /> + ))} + + +
+ ) +} + +function SaveFeedback({ + status, + error, +}: { + status: HistorySaveStatus + error: string | null +}) { + if (status === 'idle') { + return ( +

+ Se guardará la producción actual con los cristales usados ( + {/* placeholder para texto */} verTotales). El cálculo se almacena con los precios y + fórmulas de este momento. +

+ ) + } + if (status === 'saving') { + return ( +

+ + Guardando en historial… +

+ ) + } + if (status === 'saved') { + return ( +

+ + Producción guardada ✓. La podés ver en la pestaña Historial. +

+ ) + } + return ( +

Error al guardar: {error}

+ ) +} diff --git a/src/frontend/src/components/FormulasSection.tsx b/src/frontend/src/components/FormulasSection.tsx new file mode 100644 index 0000000..49ed476 --- /dev/null +++ b/src/frontend/src/components/FormulasSection.tsx @@ -0,0 +1,111 @@ +import type { Formula } from '../types' +import { parseNumber } from '../utils/format' +import type { AppState } from '../api/client' + +interface FormulasSectionProps { + state: AppState + onChange: (next: AppState | ((prev: AppState) => AppState)) => void +} + +export function FormulasSection({ state, onChange }: FormulasSectionProps) { + const formulas = state.formulas + + const updateField = (id: string, patch: Partial) => { + onChange((prev) => ({ + ...prev, + formulas: prev.formulas.map((f) => (f.id === id ? { ...f, ...patch } : f)), + })) + } + + return ( +
+
+

Recetas de crafteo

+

+ Edita los recursos necesarios por cada acción de crafteo. Los cambios se reflejan en la + calculadora y se persisten automáticamente. +

+
+ +
+ + + + + + + + + + + + + {formulas.map((f) => { + const isSoul = f.tipo === 'Soulshot' + return ( + + + + + + + + + ) + })} + +
TipoGradoCristales req.Soul Ore req.Spirit Ore req.Shots obtenidos
{f.tipo} + + {f.grado} + + + + updateField(f.id, { cristalesReq: parseNumber(e.target.value) }) + } + className="input-editable" + /> + + + updateField(f.id, { soulOreReq: parseNumber(e.target.value) }) + } + className="input-editable" + /> + + + updateField(f.id, { spiritOreReq: parseNumber(e.target.value) }) + } + className="input-editable" + /> + + + updateField(f.id, { shotsObtenidos: parseNumber(e.target.value) }) + } + className="input-editable" + /> +
+
+
+ ) +} diff --git a/src/frontend/src/components/HistorySection.tsx b/src/frontend/src/components/HistorySection.tsx new file mode 100644 index 0000000..c50b342 --- /dev/null +++ b/src/frontend/src/components/HistorySection.tsx @@ -0,0 +1,44 @@ +import { useState } from 'react' +import { useHistory } from '../hooks/useHistory' +import { StatsCards } from './StatsCards' +import { RunsTable } from './RunsTable' +import { RunDetailsModal } from './RunDetailsModal' + +export function HistorySection() { + const { runs, stats, loading, deleteRun } = useHistory() + const [selectedRunId, setSelectedRunId] = useState(null) + + if (loading && !stats) { + return ( +
+

Cargando historial…

+
+ ) + } + + return ( +
+ {stats && } + +
+
+

+ Corridas guardadas +

+ {runs.length > 0 && ( + + {runs.length} en total · click en una fila para ver detalle + + )} +
+ +
+ + setSelectedRunId(null)} /> +
+ ) +} diff --git a/src/frontend/src/components/InsumosSection.tsx b/src/frontend/src/components/InsumosSection.tsx new file mode 100644 index 0000000..ff0a5f9 --- /dev/null +++ b/src/frontend/src/components/InsumosSection.tsx @@ -0,0 +1,140 @@ +import { GRADOS, SHOT_TYPES, type Insumos } from '../types' +import { formatAdena, parseNumber } from '../utils/format' +import type { AppState } from '../api/client' + +interface InsumosSectionProps { + state: AppState + onChange: (next: AppState | ((prev: AppState) => AppState)) => void +} + +export function InsumosSection({ state, onChange }: InsumosSectionProps) { + const insumos = state.insumos + + const updateInsumos = (updater: (curr: Insumos) => Insumos) => { + onChange((prev) => ({ ...prev, insumos: updater(prev.insumos) })) + } + + const updateCristal = (grado: string, value: number) => + updateInsumos((curr) => ({ + ...curr, + cristales: { ...curr.cristales, [grado]: value }, + })) + + const updateSale = (tipo: string, grado: string, value: number) => + updateInsumos((curr) => ({ + ...curr, + venta: { + ...curr.venta, + [tipo]: { ...curr.venta[tipo], [grado]: value }, + }, + })) + + return ( +
+
+ + {GRADOS.map((grado) => ( + updateCristal(grado, v)} + /> + ))} + +
+ +
+ + updateInsumos((curr) => ({ ...curr, soulOre: v }))} + /> + updateInsumos((curr) => ({ ...curr, spiritOre: v }))} + /> + +
+ + {SHOT_TYPES.map((tipo) => ( +
+ + {GRADOS.map((grado) => ( + updateSale(tipo, grado, v)} + /> + ))} + +
+ ))} +
+ ) +} + +function Section({ + title, + subtitle, + children, +}: { + title: string + subtitle?: string + children: React.ReactNode +}) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
{children}
+
+ ) +} + +function GradeGrid({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ) +} + +function GradeRow({ + label, + value, + onChange, +}: { + label: string + value: number + onChange: (v: number) => void +}) { + return ( + + ) +} diff --git a/src/frontend/src/components/RunDetailsModal.tsx b/src/frontend/src/components/RunDetailsModal.tsx new file mode 100644 index 0000000..0c2ccb1 --- /dev/null +++ b/src/frontend/src/components/RunDetailsModal.tsx @@ -0,0 +1,324 @@ +import { useEffect, useState } from 'react' +import { historyApi, type RunDetails } from '../api/history' +import { formatAdena } from '../utils/format' +import { GRADOS, SHOT_TYPES, type Formula, type Insumos } from '../types' + +interface RunDetailsModalProps { + runId: string | null + onClose: () => void +} + +export function RunDetailsModal({ runId, onClose }: RunDetailsModalProps) { + const [details, setDetails] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [showSnapshot, setShowSnapshot] = useState(false) + + useEffect(() => { + if (!runId) { + setDetails(null) + setError(null) + return + } + setLoading(true) + setError(null) + historyApi + .getRun(runId) + .then((d) => setDetails(d)) + .catch((e) => setError(e instanceof Error ? e.message : 'Error')) + .finally(() => setLoading(false)) + }, [runId]) + + if (!runId) return null + + return ( +
+
e.stopPropagation()} + > +
+
+

+ {details?.label || 'Detalle de producción'} +

+ {details && ( +

+ {formatDateTime(details.createdAt)} +

+ )} +
+ +
+ +
+ {loading &&

Cargando…

} + {error && ( +

+ {error} +

+ )} + + {details && ( + <> +
+ + + 0 ? 'emerald' : 'rose'} + highlight + /> + +
+ +
+

+ Desglose por grado +

+
+ + + + + + + + + + + + + + + + {SHOT_TYPES.flatMap((tipo) => + GRADOS.map((grado) => { + const item = details.items.find( + (it) => it.tipo === tipo && it.grado === grado, + ) + if (!item) { + return ( + + + + + + ) + } + return ( + + + + + + + + + + + + ) + }), + )} + +
TipoGradoCristalesOreCrafteosShotsCostoVentaGanancia
{tipo} + + {grado} + + + (sin producción) +
{item.tipo} + + {item.grado} + + + {item.cristalesUsados} / {item.cristalesDisponibles} + + {item.oreNecesario} + + {item.crafteosPosibles} + + {item.shotsObtenidos} + + {formatAdena(item.costoTotal)} + + {formatAdena(item.valorVenta)} + + {formatAdena(item.ganancia)} +
+
+
+ +
+ + {showSnapshot && ( +
+ + +
+ )} +
+ + )} +
+ +
+ +
+
+
+ ) +} + +function SummaryStat({ + label, + value, + tone, + highlight = false, +}: { + label: string + value: string + tone: 'slate' | 'emerald' | 'rose' + highlight?: boolean +}) { + const colorClass = + tone === 'emerald' ? 'text-emerald-700' : tone === 'rose' ? 'text-rose-700' : 'text-slate-700' + return ( +
+

{label}

+

+ {value} +

+
+ ) +} + +function SnapshotInsumos({ snapshot }: { snapshot: RunDetails['snapshot'] }) { + const insumos = snapshot.insumos as Insumos + if (!insumos || !insumos.cristales) { + return ( +
+ Sin datos de insumos. +
+ ) + } + return ( +
+

Insumos

+ + + {Object.entries(insumos.cristales).map(([grado, precio]) => ( + + + + + ))} + + + + + + + + + +
Cristal {grado}{formatAdena(Number(precio))}
Soul Ore{formatAdena(insumos.soulOre)}
Spirit Ore{formatAdena(insumos.spiritOre)}
+
+ ) +} + +function SnapshotFormulas({ snapshot }: { snapshot: RunDetails['snapshot'] }) { + const formulas = snapshot.formulas as Formula[] + if (!formulas || !Array.isArray(formulas)) { + return ( +
+ Sin datos de fórmulas. +
+ ) + } + return ( +
+

+ Fórmulas ({formulas.length}) +

+
+ + + + + + + + + + + + + {formulas.map((f) => ( + + + + + + + + + ))} + +
TipoGradoCristSoulSpiritShots
{f.tipo}{f.grado}{f.cristalesReq}{f.soulOreReq ?? '—'}{f.spiritOreReq ?? '—'}{f.shotsObtenidos}
+
+
+ ) +} + +function formatDateTime(iso: string): string { + const d = new Date(iso) + if (isNaN(d.getTime())) return iso + return d.toLocaleString('es-ES', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +function gananciaColor(value: number): string { + if (value > 0) return 'text-emerald-700' + if (value < 0) return 'text-rose-700' + return 'text-slate-700' +} diff --git a/src/frontend/src/components/RunsTable.tsx b/src/frontend/src/components/RunsTable.tsx new file mode 100644 index 0000000..1d8b76c --- /dev/null +++ b/src/frontend/src/components/RunsTable.tsx @@ -0,0 +1,94 @@ +import type { RunSummary } from '../api/history' +import { formatAdena } from '../utils/format' + +interface RunsTableProps { + runs: RunSummary[] + onSelect: (id: string) => void + onDelete: (id: string) => void +} + +export function RunsTable({ runs, onSelect, onDelete }: RunsTableProps) { + if (runs.length === 0) { + return ( +
+

No hay corridas para mostrar.

+
+ ) + } + + return ( +
+
+ + + + + + + + + + + + + + + {runs.map((r) => ( + onSelect(r.id)} + > + + + + + + + + + + ))} + +
FechaLabelCristalesShotsCostoVentaGananciaAcción
+ {formatDateTime(r.createdAt)} + + {r.label || (sin label)} + {r.totalCristalesUsed}{r.totalShots}{formatAdena(r.totalCost)}{formatAdena(r.totalSale)} + {formatAdena(r.totalProfit)} + + +
+
+
+ ) +} + +function formatDateTime(iso: string): string { + const d = new Date(iso) + if (isNaN(d.getTime())) return iso + const yyyy = d.getFullYear() + const mm = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + const hh = String(d.getHours()).padStart(2, '0') + const min = String(d.getMinutes()).padStart(2, '0') + return `${yyyy}-${mm}-${dd} ${hh}:${min}` +} + +function gananciaColor(value: number): string { + if (value > 0) return 'text-emerald-700' + if (value < 0) return 'text-rose-700' + return 'text-slate-700' +} diff --git a/src/frontend/src/components/SaveIndicator.tsx b/src/frontend/src/components/SaveIndicator.tsx new file mode 100644 index 0000000..345082b --- /dev/null +++ b/src/frontend/src/components/SaveIndicator.tsx @@ -0,0 +1,34 @@ +import type { SaveStatus } from '../hooks/usePersistedState' + +interface SaveIndicatorProps { + status: SaveStatus + errorMessage: string | null +} + +export function SaveIndicator({ status, errorMessage }: SaveIndicatorProps) { + if (status === 'idle') { + return Guardado automáticamente + } + if (status === 'saving') { + return ( + + + Guardando… + + ) + } + if (status === 'saved') { + return ( + + + Guardado ✓ + + ) + } + return ( + + + Error al guardar + + ) +} diff --git a/src/frontend/src/components/ShotTable.tsx b/src/frontend/src/components/ShotTable.tsx new file mode 100644 index 0000000..883f957 --- /dev/null +++ b/src/frontend/src/components/ShotTable.tsx @@ -0,0 +1,123 @@ +import { GRADOS, type Calculo, type Formula, type OreType, type ShotType, type Totales } from '../types' +import { formatAdena, parseNumber } from '../utils/format' + +interface ShotTableProps { + tipo: ShotType + formulas: Formula[] + calculos: Calculo[] + totales: Totales + disponibles: Record + onChangeDisponibles: (grado: string, value: number) => void +} + +export function ShotTable({ + tipo, + formulas, + calculos, + totales, + disponibles, + onChangeDisponibles, +}: ShotTableProps) { + const formulasPorTipo = formulas.filter((f) => f.tipo === tipo) + const calcPorGrado = new Map(calculos.map((c) => [c.grado, c])) + const oreLabel: OreType = tipo === 'Soulshot' ? 'Soul Ore' : 'Spirit Ore' + const colOre = `${oreLabel} nec.` + + return ( +
+
+

{tipo}

+

+ Crafteo de {tipo}. Ingresa solo los cristales disponibles por grado. +

+
+ +
+ + + + + + + + + + + + + + + + {formulasPorTipo.map((f) => { + const c = calcPorGrado.get(f.grado) + if (!c) return null + return ( + + + + + + + + + + + + ) + })} + + + + + + + + + + + + + + +
GradoCristales disp.Cristales usados{colOre}CrafteosShotsCostoVentaGanancia
+ + {f.grado} + + + onChangeDisponibles(f.grado, parseNumber(e.target.value))} + className="input-editable" + /> + {formatAdena(c.cristalesUsados)}{formatAdena(c.oreNecesario)}{formatAdena(c.crafteosPosibles)}{formatAdena(c.shotsObtenidos)}{formatAdena(c.costoTotal)}{formatAdena(c.valorVenta)} + {formatAdena(c.ganancia)} +
Σ{formatAdena(totales.cristalesUsados)}{formatAdena(totales.oreNecesario)}{formatAdena(totales.crafteosPosibles)}{formatAdena(totales.shotsObtenidos)}{formatAdena(totales.costoTotal)}{formatAdena(totales.valorVenta)} + {formatAdena(totales.ganancia)} +
+
+ + {GRADOS.some((g) => { + const c = calcPorGrado.get(g) + const f = formulasPorTipo.find((x) => x.grado === g) + return c?.warning || (f && f.cristalesReq === 0) + }) && ( +

+ ⚠️ Una o más filas tienen cristales requeridos en 0. Completa la receta en la pestaña + Fórmulas para ver el cálculo. +

+ )} +
+ ) +} + +function gananciaColor(value: number): string { + if (value > 0) return 'text-emerald-700' + if (value < 0) return 'text-rose-700' + return 'text-slate-700' +} diff --git a/src/frontend/src/components/StatsCards.tsx b/src/frontend/src/components/StatsCards.tsx new file mode 100644 index 0000000..ebe58de --- /dev/null +++ b/src/frontend/src/components/StatsCards.tsx @@ -0,0 +1,80 @@ +import type { HistoryStats } from '../api/history' +import { formatAdena } from '../utils/format' + +interface StatsCardsProps { + stats: HistoryStats +} + +export function StatsCards({ stats }: StatsCardsProps) { + if (stats.totalRuns === 0) { + return ( +
+

+ Aún no hay producciones guardadas. Ve a la pestaña Calculadora, + configura los cristales disponibles y presiona Guardar producción. +

+
+ ) + } + + return ( +
+ 0 ? 'emerald' : stats.totalProfit < 0 ? 'rose' : 'slate'} + subtitle={`${stats.totalRuns} corrida${stats.totalRuns === 1 ? '' : 's'}`} + /> + 0 ? 'emerald' : stats.avgProfit < 0 ? 'rose' : 'slate'} + subtitle="por corrida" + /> + + + 0 ? 'emerald' : stats.last5Avg < 0 ? 'rose' : 'slate'} + subtitle="tendencia reciente" + /> +
+ ) +} + +function MetricCard({ + label, + value, + subtitle, + tone, +}: { + label: string + value: string + subtitle?: string + tone: 'slate' | 'emerald' | 'rose' +}) { + const colorClass = + tone === 'emerald' + ? 'text-emerald-700' + : tone === 'rose' + ? 'text-rose-700' + : 'text-slate-700' + return ( +
+

{label}

+

{value}

+ {subtitle &&

{subtitle}

} +
+ ) +} diff --git a/src/frontend/src/components/TabBar.tsx b/src/frontend/src/components/TabBar.tsx new file mode 100644 index 0000000..9d860c2 --- /dev/null +++ b/src/frontend/src/components/TabBar.tsx @@ -0,0 +1,42 @@ +export type TabKey = 'insumos' | 'formulas' | 'calculadora' | 'historial' + +interface TabBarProps { + active: TabKey + onChange: (key: TabKey) => void +} + +const TABS: Array<{ key: TabKey; label: string; subtitle: string }> = [ + { key: 'insumos', label: '1. Insumos', subtitle: 'Precios' }, + { key: 'formulas', label: '2. Fórmulas', subtitle: 'Recetas' }, + { key: 'calculadora', label: '3. Calculadora', subtitle: 'Rentabilidad' }, + { key: 'historial', label: '4. Historial', subtitle: 'Producción' }, +] + +export function TabBar({ active, onChange }: TabBarProps) { + return ( +
+ +
+ ) +} diff --git a/src/frontend/src/components/TotalsSummary.tsx b/src/frontend/src/components/TotalsSummary.tsx new file mode 100644 index 0000000..d0350a6 --- /dev/null +++ b/src/frontend/src/components/TotalsSummary.tsx @@ -0,0 +1,77 @@ +import type { Totales } from '../types' +import { formatAdena } from '../utils/format' + +interface TotalsSummaryProps { + totales: Totales +} + +export function TotalsSummary({ totales }: TotalsSummaryProps) { + const margen = totales.costoTotal > 0 + ? (totales.ganancia / totales.costoTotal) * 100 + : 0 + + return ( +
0 + ? 'bg-emerald-50 border-emerald-200' + : totales.ganancia < 0 + ? 'bg-rose-50 border-rose-200' + : 'bg-slate-50 border-slate-200', + ].join(' ')} + > +
+

Resumen global

+

+ Suma de Soulshots, Spiritshots y Blessed Spiritshots +

+
+ +
+ + + 0 ? 'emerald' : totales.ganancia < 0 ? 'rose' : 'slate'} + highlight + /> + 0 ? 'emerald' : margen < 0 ? 'rose' : 'slate'} + /> +
+
+ ) +} + +function Metric({ + label, + value, + tone, + highlight = false, +}: { + label: string + value: string + tone: 'slate' | 'emerald' | 'rose' + highlight?: boolean +}) { + const colorClass = + tone === 'emerald' ? 'text-emerald-700' : tone === 'rose' ? 'text-rose-700' : 'text-slate-800' + return ( +
+

{label}

+

+ {value} +

+
+ ) +} diff --git a/src/frontend/src/hooks/useHistory.ts b/src/frontend/src/hooks/useHistory.ts new file mode 100644 index 0000000..0d089a8 --- /dev/null +++ b/src/frontend/src/hooks/useHistory.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useState } from 'react' +import { historyApi, type HistoryStats, type RunIn, type RunSummary } from '../api/history' + +export type HistorySaveStatus = 'idle' | 'saving' | 'saved' | 'error' + +interface UseHistoryResult { + runs: RunSummary[] + stats: HistoryStats | null + loading: boolean + saveStatus: HistorySaveStatus + saveError: string | null + reload: () => Promise + saveRun: (payload: RunIn) => Promise + deleteRun: (id: string) => Promise +} + +export function useHistory(): UseHistoryResult { + const [runs, setRuns] = useState([]) + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [saveStatus, setSaveStatus] = useState('idle') + const [saveError, setSaveError] = useState(null) + + const reload = useCallback(async () => { + setLoading(true) + try { + const [runsData, statsData] = await Promise.all([ + historyApi.listRuns(), + historyApi.getStats(), + ]) + setRuns(runsData) + setStats(statsData) + } catch (e) { + console.error('history load failed', e) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + reload() + }, [reload]) + + const saveRun = useCallback(async (payload: RunIn) => { + setSaveStatus('saving') + setSaveError(null) + try { + const saved = await historyApi.saveRun(payload) + setRuns((prev) => [saved, ...prev]) + const newStats = await historyApi.getStats() + setStats(newStats) + setSaveStatus('saved') + window.setTimeout(() => setSaveStatus((s) => (s === 'saved' ? 'idle' : s)), 3000) + return saved + } catch (e) { + setSaveStatus('error') + setSaveError(e instanceof Error ? e.message : 'Error al guardar') + throw e + } + }, []) + + const deleteRun = useCallback(async (id: string) => { + await historyApi.deleteRun(id) + setRuns((prev) => prev.filter((r) => r.id !== id)) + const newStats = await historyApi.getStats() + setStats(newStats) + }, []) + + return { runs, stats, loading, saveStatus, saveError, reload, saveRun, deleteRun } +} diff --git a/src/frontend/src/hooks/usePersistedState.ts b/src/frontend/src/hooks/usePersistedState.ts new file mode 100644 index 0000000..268ab47 --- /dev/null +++ b/src/frontend/src/hooks/usePersistedState.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { api, ApiError, type AppState } from '../api/client' +import { makeDefaultAppState } from '../data/defaults' + +export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' + +interface UsePersistedStateResult { + state: AppState | null + setState: (next: AppState | ((prev: AppState) => AppState)) => void + status: SaveStatus + errorMessage: string | null + reset: () => Promise + reload: () => Promise +} + +const DEBOUNCE_MS = 500 + +export function usePersistedState(): UsePersistedStateResult { + const [state, setStateInternal] = useState(null) + const [status, setStatus] = useState('idle') + const [errorMessage, setErrorMessage] = useState(null) + const stateRef = useRef(null) + const timerRef = useRef(null) + const firstLoad = useRef(true) + + const load = useCallback(async () => { + try { + const loaded = await api.getState() + setStateInternal(loaded ?? makeDefaultAppState()) + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + setStateInternal(null) + } else { + setErrorMessage(err instanceof Error ? err.message : 'Error cargando estado') + } + } + }, []) + + useEffect(() => { + load() + }, [load]) + + useEffect(() => { + stateRef.current = state + }, [state]) + + useEffect(() => { + if (firstLoad.current) { + firstLoad.current = false + return + } + if (!state) return + + setStatus('saving') + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current) + } + timerRef.current = window.setTimeout(async () => { + try { + if (stateRef.current) { + await api.putState(stateRef.current) + } + setStatus('saved') + setErrorMessage(null) + window.setTimeout(() => setStatus((s) => (s === 'saved' ? 'idle' : s)), 1500) + } catch (err) { + setStatus('error') + setErrorMessage(err instanceof Error ? err.message : 'Error al guardar') + } + }, DEBOUNCE_MS) + + return () => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current) + } + } + }, [state]) + + const setState = useCallback((next: AppState | ((prev: AppState) => AppState)) => { + setStateInternal((prev) => { + if (typeof next === 'function') { + return (next as (prev: AppState) => AppState)(prev as AppState) + } + return next + }) + }, []) + + const reset = useCallback(async () => { + await api.resetState() + setStateInternal(makeDefaultAppState()) + }, []) + + return { state, setState, status, errorMessage, reset, reload: load } +} diff --git a/src/frontend/src/index.css b/src/frontend/src/index.css new file mode 100644 index 0000000..95430f3 --- /dev/null +++ b/src/frontend/src/index.css @@ -0,0 +1,34 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } + body { + @apply bg-slate-50 text-slate-900; + } +} + +@layer components { + .input-editable { + @apply w-full bg-editable-50 border border-editable-200 rounded px-2 py-1 text-right font-mono text-sm + focus:outline-none focus:ring-2 focus:ring-editable-400 focus:border-editable-400; + } + .input-editable:disabled { + @apply bg-slate-100 text-slate-400 cursor-not-allowed; + } + .cell-calculated { + @apply bg-calculated-100 text-slate-700 font-mono text-sm text-right px-2 py-1; + } + .cell-label { + @apply px-3 py-2 text-sm font-semibold text-slate-700 bg-slate-100 border-b border-slate-200; + } + .cell-input { + @apply px-2 py-1 border-b border-slate-200; + } + .cell-calc { + @apply px-2 py-1 border-b border-slate-200 bg-calculated-50; + } +} diff --git a/src/frontend/src/main.tsx b/src/frontend/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/src/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/frontend/src/types.ts b/src/frontend/src/types.ts new file mode 100644 index 0000000..5b153ef --- /dev/null +++ b/src/frontend/src/types.ts @@ -0,0 +1,53 @@ +export type Grado = 'D' | 'C' | 'B' | 'A' | 'S' + +export const GRADOS: Grado[] = ['D', 'C', 'B', 'A', 'S'] + +export type ShotType = 'Soulshot' | 'Spiritshot' | 'Blessed Spiritshot' + +export const SHOT_TYPES: ShotType[] = ['Soulshot', 'Spiritshot', 'Blessed Spiritshot'] + +export type OreType = 'Soul Ore' | 'Spirit Ore' + +export interface Insumos { + cristales: Record + soulOre: number + spiritOre: number + venta: Record> +} + +export interface Formula { + id: string + tipo: string + grado: string + cristalesReq: number + soulOreReq: number | null + spiritOreReq: number | null + shotsObtenidos: number +} + +export type CristalesDisponibles = Record> + +export interface Calculo { + tipo: string + grado: string + cristalesDisponibles: number + cristalesUsados: number + oreNecesario: number + oreLabel: OreType | null + crafteosPosibles: number + shotsObtenidos: number + costoTotal: number + valorVenta: number + ganancia: number + warning: string | null +} + +export interface Totales { + cristalesUsados: number + oreNecesario: number + crafteosPosibles: number + shotsObtenidos: number + costoTotal: number + valorVenta: number + ganancia: number +} diff --git a/src/frontend/src/utils/calc.ts b/src/frontend/src/utils/calc.ts new file mode 100644 index 0000000..e32b553 --- /dev/null +++ b/src/frontend/src/utils/calc.ts @@ -0,0 +1,83 @@ +import type { Calculo, Formula, Insumos, OreType, Totales } from '../types' + +export function calcularFila( + formula: Formula, + cristalesDisponibles: number, + insumos: Insumos, +): Calculo { + const orePerCraft = formula.soulOreReq ?? formula.spiritOreReq ?? 0 + const oreLabel: OreType | null = + formula.soulOreReq != null ? 'Soul Ore' : formula.spiritOreReq != null ? 'Spirit Ore' : null + + if (formula.cristalesReq <= 0) { + return { + tipo: formula.tipo, + grado: formula.grado, + cristalesDisponibles, + cristalesUsados: 0, + oreNecesario: 0, + oreLabel, + crafteosPosibles: 0, + shotsObtenidos: 0, + costoTotal: 0, + valorVenta: 0, + ganancia: 0, + warning: 'Completa la receta en la pestaña Fórmulas', + } + } + + const crafteosPosibles = Math.floor(cristalesDisponibles / formula.cristalesReq) + const cristalesUsados = crafteosPosibles * formula.cristalesReq + const oreNecesario = crafteosPosibles * orePerCraft + const shotsObtenidos = crafteosPosibles * formula.shotsObtenidos + + const precioCristal = insumos.cristales[formula.grado] + const precioOre = orePerCraft > 0 + ? formula.soulOreReq != null + ? insumos.soulOre + : insumos.spiritOre + : 0 + const precioVenta = insumos.venta[formula.tipo][formula.grado] + + const costoTotal = cristalesUsados * precioCristal + oreNecesario * precioOre + const valorVenta = shotsObtenidos * precioVenta + const ganancia = valorVenta - costoTotal + + return { + tipo: formula.tipo, + grado: formula.grado, + cristalesDisponibles, + cristalesUsados, + oreNecesario, + oreLabel, + crafteosPosibles, + shotsObtenidos, + costoTotal, + valorVenta, + ganancia, + warning: null, + } +} + +export function sumarTotales(calculos: Calculo[]): Totales { + return calculos.reduce( + (acc, c) => ({ + cristalesUsados: acc.cristalesUsados + c.cristalesUsados, + oreNecesario: acc.oreNecesario + c.oreNecesario, + crafteosPosibles: acc.crafteosPosibles + c.crafteosPosibles, + shotsObtenidos: acc.shotsObtenidos + c.shotsObtenidos, + costoTotal: acc.costoTotal + c.costoTotal, + valorVenta: acc.valorVenta + c.valorVenta, + ganancia: acc.ganancia + c.ganancia, + }), + { + cristalesUsados: 0, + oreNecesario: 0, + crafteosPosibles: 0, + shotsObtenidos: 0, + costoTotal: 0, + valorVenta: 0, + ganancia: 0, + }, + ) +} diff --git a/src/frontend/src/utils/format.ts b/src/frontend/src/utils/format.ts new file mode 100644 index 0000000..07b4e10 --- /dev/null +++ b/src/frontend/src/utils/format.ts @@ -0,0 +1,16 @@ +const formatter = new Intl.NumberFormat('es-ES', { maximumFractionDigits: 0 }) + +export function formatAdena(n: number): string { + if (!Number.isFinite(n)) return '0' + return formatter.format(Math.round(n)) +} + +export function formatNumber(n: number): string { + return formatter.format(n) +} + +export function parseNumber(value: string): number { + if (value === '' || value === '-') return 0 + const n = Number(value) + return Number.isFinite(n) && n >= 0 ? n : 0 +} diff --git a/src/frontend/tailwind.config.js b/src/frontend/tailwind.config.js new file mode 100644 index 0000000..691c202 --- /dev/null +++ b/src/frontend/tailwind.config.js @@ -0,0 +1,26 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + editable: { + 50: '#fffbeb', + 100: '#fef3c7', + 200: '#fde68a', + 400: '#fbbf24', + 500: '#f59e0b', + }, + calculated: { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + }, + }, + fontFamily: { + mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'], + }, + }, + }, + plugins: [], +} diff --git a/src/frontend/tsconfig.app.json b/src/frontend/tsconfig.app.json new file mode 100644 index 0000000..c95ee7f --- /dev/null +++ b/src/frontend/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/src/frontend/tsconfig.json b/src/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/src/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/src/frontend/tsconfig.node.json b/src/frontend/tsconfig.node.json new file mode 100644 index 0000000..8e5b203 --- /dev/null +++ b/src/frontend/tsconfig.node.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts new file mode 100644 index 0000000..fc1203c --- /dev/null +++ b/src/frontend/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + base: './', + build: { + outDir: 'dist', + emptyOutDir: true, + }, + server: { + port: 5173, + proxy: { + '/api': 'http://localhost:8080', + }, + }, +}) diff --git a/src/main/java/com/l2/shots/auth/AuthMeResponse.java b/src/main/java/com/l2/shots/auth/AuthMeResponse.java new file mode 100644 index 0000000..51b34c8 --- /dev/null +++ b/src/main/java/com/l2/shots/auth/AuthMeResponse.java @@ -0,0 +1,18 @@ +package com.l2.shots.auth; + +import java.time.Instant; +import java.util.UUID; + +public class AuthMeResponse { + public UUID id; + public String username; + public Instant createdAt; + + public AuthMeResponse() {} + + public AuthMeResponse(UUID id, String username, Instant createdAt) { + this.id = id; + this.username = username; + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/l2/shots/auth/AuthResource.java b/src/main/java/com/l2/shots/auth/AuthResource.java new file mode 100644 index 0000000..1262bf0 --- /dev/null +++ b/src/main/java/com/l2/shots/auth/AuthResource.java @@ -0,0 +1,123 @@ +package com.l2.shots.auth; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.NewCookie; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.Optional; + +@Path("/api/auth") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class AuthResource { + + @Inject + AuthService authService; + + @Inject + JwtCookieAuth jwtCookieAuth; + + @ConfigProperty(name = "app.auth.cookie-name") + String cookieName; + + @ConfigProperty(name = "app.auth.cookie-max-age-seconds") + int cookieMaxAge; + + @POST + @Path("/register") + public Response register(Credentials creds) { + Optional result = authService.register(creds.username, creds.password); + if (result.isEmpty()) { + return Response.status(409) + .entity(new ErrorBody("username no disponible o datos inválidos")) + .build(); + } + User user = result.get(); + String token = authService.buildToken(user.id); + return Response.ok(AuthService.toAuthMe(user)) + .cookie(buildAuthCookie(token)) + .build(); + } + + @POST + @Path("/login") + public Response login(Credentials creds) { + Optional result = authService.authenticate(creds.username, creds.password); + if (result.isEmpty()) { + return Response.status(401) + .entity(new ErrorBody("credenciales inválidas")) + .build(); + } + User user = result.get(); + String token = authService.buildToken(user.id); + return Response.ok(AuthService.toAuthMe(user)) + .cookie(buildAuthCookie(token)) + .build(); + } + + @POST + @Path("/logout") + public Response logout() { + return Response.noContent() + .cookie(clearAuthCookie()) + .build(); + } + + @GET + @Path("/me") + public Response me(@Context HttpHeaders headers) { + Optional jwt = jwtCookieAuth.extractToken(headers); + if (jwt.isEmpty()) { + return Response.status(401).build(); + } + return authService.getUserFromToken(jwt.get()) + .map(u -> Response.ok(u).build()) + .orElse(Response.status(401).build()); + } + + @GET + @Path("/check") + @Authenticated + public Response check() { + return Response.ok().build(); + } + + private NewCookie buildAuthCookie(String token) { + return new NewCookie.Builder(cookieName) + .value(token) + .path("/") + .httpOnly(true) + .secure(false) + .sameSite(NewCookie.SameSite.LAX) + .maxAge(cookieMaxAge) + .build(); + } + + private NewCookie clearAuthCookie() { + return new NewCookie.Builder(cookieName) + .value("") + .path("/") + .httpOnly(true) + .secure(false) + .sameSite(NewCookie.SameSite.LAX) + .maxAge(0) + .build(); + } + + public static class ErrorBody { + public String error; + public ErrorBody() {} + public ErrorBody(String error) { this.error = error; } + } +} diff --git a/src/main/java/com/l2/shots/auth/AuthService.java b/src/main/java/com/l2/shots/auth/AuthService.java new file mode 100644 index 0000000..26d90c6 --- /dev/null +++ b/src/main/java/com/l2/shots/auth/AuthService.java @@ -0,0 +1,75 @@ +package com.l2.shots.auth; + +import io.quarkus.elytron.security.common.BcryptUtil; +import io.smallrye.jwt.build.Jwt; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +@ApplicationScoped +public class AuthService { + + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_]{3,30}$"); + public static final int MIN_PASSWORD_LENGTH = 8; + + @ConfigProperty(name = "mp.jwt.verify.issuer") + String issuer; + + @Transactional + public Optional register(String username, String password) { + if (username == null || password == null) return Optional.empty(); + username = username.trim(); + if (!USERNAME_PATTERN.matcher(username).matches()) return Optional.empty(); + if (password.length() < MIN_PASSWORD_LENGTH) return Optional.empty(); + if (User.findByUsernameCaseInsensitive(username) != null) return Optional.empty(); + + User user = new User(); + user.id = UUID.randomUUID(); + user.username = username.toLowerCase(); + user.passwordHash = BcryptUtil.bcryptHash(password); + user.createdAt = Instant.now(); + user.persist(); + + return Optional.of(user); + } + + public Optional authenticate(String username, String password) { + if (username == null || password == null) return Optional.empty(); + User user = User.findByUsernameCaseInsensitive(username.trim()); + if (user == null) return Optional.empty(); + if (!BcryptUtil.matches(password, user.passwordHash)) return Optional.empty(); + return Optional.of(user); + } + + public String buildToken(UUID userId) { + return Jwt.issuer(issuer) + .subject(userId.toString()) + .groups(Set.of("user")) + .expiresIn(Duration.ofSeconds(86400)) + .sign(); + } + + public Optional getUserFromToken(JsonWebToken jwt) { + if (jwt == null || jwt.getSubject() == null) return Optional.empty(); + try { + UUID userId = UUID.fromString(jwt.getSubject()); + User user = User.findById(userId); + if (user == null) return Optional.empty(); + return Optional.of(new AuthMeResponse(user.id, user.username, user.createdAt)); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + public static AuthMeResponse toAuthMe(User user) { + return new AuthMeResponse(user.id, user.username, user.createdAt); + } +} diff --git a/src/main/java/com/l2/shots/auth/Credentials.java b/src/main/java/com/l2/shots/auth/Credentials.java new file mode 100644 index 0000000..21f7dcd --- /dev/null +++ b/src/main/java/com/l2/shots/auth/Credentials.java @@ -0,0 +1,6 @@ +package com.l2.shots.auth; + +public class Credentials { + public String username; + public String password; +} diff --git a/src/main/java/com/l2/shots/auth/JwtCookieAuth.java b/src/main/java/com/l2/shots/auth/JwtCookieAuth.java new file mode 100644 index 0000000..af4c966 --- /dev/null +++ b/src/main/java/com/l2/shots/auth/JwtCookieAuth.java @@ -0,0 +1,45 @@ +package com.l2.shots.auth; + +import io.smallrye.jwt.auth.principal.JWTParser; +import io.smallrye.jwt.auth.principal.ParseException; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import jakarta.ws.rs.core.Cookie; +import jakarta.ws.rs.core.HttpHeaders; + +import java.util.Map; +import java.util.Optional; + +@ApplicationScoped +public class JwtCookieAuth { + + @Inject + JWTParser parser; + + @ConfigProperty(name = "app.auth.cookie-name") + String cookieName; + + public Optional extractToken(HttpHeaders headers) { + String auth = headers.getHeaderString("Authorization"); + if (auth != null && auth.startsWith("Bearer ")) { + return Optional.of(parseToken(auth.substring(7))); + } + Map cookies = headers.getCookies(); + Cookie cookie = cookies.get(cookieName); + if (cookie != null && cookie.getValue() != null && !cookie.getValue().isEmpty()) { + return Optional.of(parseToken(cookie.getValue())); + } + return Optional.empty(); + } + + private JsonWebToken parseToken(String token) { + try { + return parser.parse(token); + } catch (ParseException e) { + return null; + } + } +} diff --git a/src/main/java/com/l2/shots/auth/User.java b/src/main/java/com/l2/shots/auth/User.java new file mode 100644 index 0000000..d0edc29 --- /dev/null +++ b/src/main/java/com/l2/shots/auth/User.java @@ -0,0 +1,35 @@ +package com.l2.shots.auth; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "users") +public class User extends PanacheEntityBase { + + @Id + public UUID id; + + @Column(unique = true, nullable = false, length = 30) + public String username; + + @Column(name = "password_hash", nullable = false, length = 100) + public String passwordHash; + + @Column(name = "created_at", nullable = false) + public Instant createdAt; + + public static User findByUsername(String username) { + return find("username", username.toLowerCase()).firstResult(); + } + + public static User findByUsernameCaseInsensitive(String username) { + return find("LOWER(username) = ?1", username.toLowerCase()).firstResult(); + } +} diff --git a/src/main/java/com/l2/shots/auth/UserState.java b/src/main/java/com/l2/shots/auth/UserState.java new file mode 100644 index 0000000..3db67ab --- /dev/null +++ b/src/main/java/com/l2/shots/auth/UserState.java @@ -0,0 +1,29 @@ +package com.l2.shots.auth; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "user_state") +public class UserState extends PanacheEntityBase { + + @Id + @Column(name = "user_id") + public UUID userId; + + @Column(name = "state_json", nullable = false, columnDefinition = "TEXT") + public String stateJson; + + @Column(name = "updated_at", nullable = false) + public Instant updatedAt; + + public static UserState findByUserId(UUID userId) { + return findById(userId); + } +} diff --git a/src/main/java/com/l2/shots/history/HistoryResource.java b/src/main/java/com/l2/shots/history/HistoryResource.java new file mode 100644 index 0000000..0cdd162 --- /dev/null +++ b/src/main/java/com/l2/shots/history/HistoryResource.java @@ -0,0 +1,120 @@ +package com.l2.shots.history; + +import com.l2.shots.auth.JwtCookieAuth; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Path("/api/history") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class HistoryResource { + + @Inject + HistoryService historyService; + + @Inject + JwtCookieAuth jwtCookieAuth; + + @POST + @Path("/runs") + public Response saveRun(@Context HttpHeaders headers, RunIn input) { + Optional userId = extractUserId(headers); + if (userId.isEmpty()) return Response.status(401).build(); + + if (input == null || input.items == null || input.snapshot == null) { + return Response.status(400).entity("{\"error\":\"payload inválido\"}").build(); + } + if (input.items.size() > 100) { + return Response.status(400).entity("{\"error\":\"demasiados items\"}").build(); + } + if (input.label != null && input.label.length() > 100) { + return Response.status(400).entity("{\"error\":\"label demasiado largo\"}").build(); + } + if (input.totalCristalesUsed <= 0) { + return Response.status(400).entity("{\"error\":\"no hay cristales usados\"}").build(); + } + + RunSummary saved = historyService.saveRun(userId.get(), input); + return Response.status(201).entity(saved).build(); + } + + @GET + @Path("/runs") + public Response list(@Context HttpHeaders headers) { + Optional userId = extractUserId(headers); + if (userId.isEmpty()) return Response.status(401).build(); + + List runs = historyService.listForUser(userId.get()); + return Response.ok(runs).build(); + } + + @GET + @Path("/runs/{id}") + public Response get(@Context HttpHeaders headers, @PathParam("id") String idStr) { + Optional userId = extractUserId(headers); + if (userId.isEmpty()) return Response.status(401).build(); + + UUID id; + try { + id = UUID.fromString(idStr); + } catch (IllegalArgumentException e) { + return Response.status(400).entity("{\"error\":\"id inválido\"}").build(); + } + + return historyService.getById(userId.get(), id) + .map(d -> Response.ok(d).build()) + .orElse(Response.status(404).build()); + } + + @DELETE + @Path("/runs/{id}") + public Response delete(@Context HttpHeaders headers, @PathParam("id") String idStr) { + Optional userId = extractUserId(headers); + if (userId.isEmpty()) return Response.status(401).build(); + + UUID id; + try { + id = UUID.fromString(idStr); + } catch (IllegalArgumentException e) { + return Response.status(400).entity("{\"error\":\"id inválido\"}").build(); + } + + boolean deleted = historyService.deleteForUser(userId.get(), id); + return deleted ? Response.noContent().build() : Response.status(404).build(); + } + + @GET + @Path("/stats") + public Response stats(@Context HttpHeaders headers) { + Optional userId = extractUserId(headers); + if (userId.isEmpty()) return Response.status(401).build(); + + HistoryStats stats = historyService.computeStats(userId.get()); + return Response.ok(stats).build(); + } + + private Optional extractUserId(HttpHeaders headers) { + Optional jwt = jwtCookieAuth.extractToken(headers); + if (jwt.isEmpty()) return Optional.empty(); + try { + return Optional.of(UUID.fromString(jwt.get().getSubject())); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/l2/shots/history/HistoryService.java b/src/main/java/com/l2/shots/history/HistoryService.java new file mode 100644 index 0000000..9f48f69 --- /dev/null +++ b/src/main/java/com/l2/shots/history/HistoryService.java @@ -0,0 +1,127 @@ +package com.l2.shots.history; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@ApplicationScoped +public class HistoryService { + + private final ObjectMapper mapper = new ObjectMapper(); + + public List listForUser(UUID userId) { + return ProductionRun.list("userId = ?1 ORDER BY createdAt DESC", userId) + .stream() + .map(RunSummary::new) + .toList(); + } + + public Optional getById(UUID userId, UUID id) { + ProductionRun entity = ProductionRun.find("id = ?1 AND userId = ?2", id, userId).firstResult(); + if (entity == null) return Optional.empty(); + try { + List items = mapper.readValue(entity.itemsJson, mapper.getTypeFactory() + .constructCollectionType(List.class, RunItem.class)); + RunSnapshot snapshot = mapper.readValue(entity.snapshotJson, RunSnapshot.class); + return Optional.of(new RunDetails(entity, items, snapshot)); + } catch (JsonProcessingException e) { + return Optional.empty(); + } + } + + @Transactional + public RunSummary saveRun(UUID userId, RunIn input) { + ProductionRun entity = new ProductionRun(); + entity.id = UUID.randomUUID(); + entity.userId = userId; + entity.createdAt = Instant.now(); + entity.label = input.label; + entity.totalCost = input.totalCost; + entity.totalSale = input.totalSale; + entity.totalProfit = input.totalProfit; + entity.totalShots = input.totalShots; + entity.totalCristalesUsed = input.totalCristalesUsed; + entity.totalOreUsed = input.totalOreUsed; + try { + entity.itemsJson = mapper.writeValueAsString(input.items); + entity.snapshotJson = mapper.writeValueAsString(input.snapshot); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize run payload", e); + } + entity.persist(); + return new RunSummary(entity); + } + + @Transactional + public boolean deleteForUser(UUID userId, UUID id) { + return ProductionRun.delete("id = ?1 AND userId = ?2", id, userId) > 0; + } + + public HistoryStats computeStats(UUID userId) { + List runs = ProductionRun.list( + "userId = ?1 ORDER BY createdAt DESC", userId); + + HistoryStats stats = new HistoryStats(); + stats.totalRuns = runs.size(); + + if (runs.isEmpty()) { + stats.totalCost = 0; + stats.totalSale = 0; + stats.totalProfit = 0; + stats.totalShots = 0; + stats.avgProfit = 0; + stats.avgCost = 0; + stats.avgSale = 0; + stats.bestRun = null; + stats.worstRun = null; + stats.last5Avg = 0; + stats.last10Avg = 0; + return stats; + } + + long totalCost = 0; + long totalSale = 0; + long totalProfit = 0; + long totalShots = 0; + ProductionRun best = runs.get(0); + ProductionRun worst = runs.get(0); + + for (ProductionRun r : runs) { + totalCost += r.totalCost; + totalSale += r.totalSale; + totalProfit += r.totalProfit; + totalShots += r.totalShots; + if (r.totalProfit > best.totalProfit) best = r; + if (r.totalProfit < worst.totalProfit) worst = r; + } + + stats.totalCost = totalCost; + stats.totalSale = totalSale; + stats.totalProfit = totalProfit; + stats.totalShots = totalShots; + stats.avgProfit = totalProfit / runs.size(); + stats.avgCost = totalCost / runs.size(); + stats.avgSale = totalSale / runs.size(); + stats.bestRun = new RunSummary(best); + stats.worstRun = new RunSummary(worst); + + int n5 = Math.min(5, runs.size()); + int n10 = Math.min(10, runs.size()); + long sum5 = 0; + long sum10 = 0; + for (int i = 0; i < n10; i++) { + sum10 += runs.get(i).totalProfit; + if (i < n5) sum5 += runs.get(i).totalProfit; + } + stats.last5Avg = n5 > 0 ? sum5 / n5 : 0; + stats.last10Avg = n10 > 0 ? sum10 / n10 : 0; + + return stats; + } +} diff --git a/src/main/java/com/l2/shots/history/HistoryStats.java b/src/main/java/com/l2/shots/history/HistoryStats.java new file mode 100644 index 0000000..2b7b2a1 --- /dev/null +++ b/src/main/java/com/l2/shots/history/HistoryStats.java @@ -0,0 +1,16 @@ +package com.l2.shots.history; + +public class HistoryStats { + public int totalRuns; + public long totalCost; + public long totalSale; + public long totalProfit; + public long totalShots; + public long avgProfit; + public long avgCost; + public long avgSale; + public RunSummary bestRun; + public RunSummary worstRun; + public long last5Avg; + public long last10Avg; +} diff --git a/src/main/java/com/l2/shots/history/ProductionRun.java b/src/main/java/com/l2/shots/history/ProductionRun.java new file mode 100644 index 0000000..1870692 --- /dev/null +++ b/src/main/java/com/l2/shots/history/ProductionRun.java @@ -0,0 +1,54 @@ +package com.l2.shots.history; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "production_runs", indexes = { + @Index(name = "idx_runs_user_created", columnList = "user_id, created_at") +}) +public class ProductionRun extends PanacheEntityBase { + + @Id + public UUID id; + + @Column(name = "user_id", nullable = false) + public UUID userId; + + @Column(name = "created_at", nullable = false) + public Instant createdAt; + + @Column(length = 100) + public String label; + + @Column(name = "total_cost", nullable = false) + public long totalCost; + + @Column(name = "total_sale", nullable = false) + public long totalSale; + + @Column(name = "total_profit", nullable = false) + public long totalProfit; + + @Column(name = "total_shots", nullable = false) + public long totalShots; + + @Column(name = "total_cristales_used", nullable = false) + public long totalCristalesUsed; + + @Column(name = "total_ore_used", nullable = false) + public long totalOreUsed; + + @Column(name = "items_json", nullable = false, columnDefinition = "TEXT") + public String itemsJson; + + @Column(name = "snapshot_json", nullable = false, columnDefinition = "TEXT") + public String snapshotJson; +} diff --git a/src/main/java/com/l2/shots/history/RunDetails.java b/src/main/java/com/l2/shots/history/RunDetails.java new file mode 100644 index 0000000..4980b55 --- /dev/null +++ b/src/main/java/com/l2/shots/history/RunDetails.java @@ -0,0 +1,16 @@ +package com.l2.shots.history; + +import java.util.List; + +public class RunDetails extends RunSummary { + public List items; + public RunSnapshot snapshot; + + public RunDetails() {} + + public RunDetails(ProductionRun r, List items, RunSnapshot snapshot) { + super(r); + this.items = items; + this.snapshot = snapshot; + } +} diff --git a/src/main/java/com/l2/shots/history/RunIn.java b/src/main/java/com/l2/shots/history/RunIn.java new file mode 100644 index 0000000..f758e06 --- /dev/null +++ b/src/main/java/com/l2/shots/history/RunIn.java @@ -0,0 +1,15 @@ +package com.l2.shots.history; + +import java.util.List; + +public class RunIn { + public String label; + public long totalCost; + public long totalSale; + public long totalProfit; + public long totalShots; + public long totalCristalesUsed; + public long totalOreUsed; + public List items; + public RunSnapshot snapshot; +} diff --git a/src/main/java/com/l2/shots/history/RunItem.java b/src/main/java/com/l2/shots/history/RunItem.java new file mode 100644 index 0000000..4e50daf --- /dev/null +++ b/src/main/java/com/l2/shots/history/RunItem.java @@ -0,0 +1,31 @@ +package com.l2.shots.history; + +public class RunItem { + public String tipo; + public String grado; + public int cristalesDisponibles; + public int cristalesUsados; + public int oreNecesario; + public int crafteosPosibles; + public int shotsObtenidos; + public long costoTotal; + public long valorVenta; + public long ganancia; + + public RunItem() {} + + public RunItem(String tipo, String grado, int cristalesDisponibles, int cristalesUsados, + int oreNecesario, int crafteosPosibles, int shotsObtenidos, + long costoTotal, long valorVenta, long ganancia) { + this.tipo = tipo; + this.grado = grado; + this.cristalesDisponibles = cristalesDisponibles; + this.cristalesUsados = cristalesUsados; + this.oreNecesario = oreNecesario; + this.crafteosPosibles = crafteosPosibles; + this.shotsObtenidos = shotsObtenidos; + this.costoTotal = costoTotal; + this.valorVenta = valorVenta; + this.ganancia = ganancia; + } +} diff --git a/src/main/java/com/l2/shots/history/RunSnapshot.java b/src/main/java/com/l2/shots/history/RunSnapshot.java new file mode 100644 index 0000000..0243d2a --- /dev/null +++ b/src/main/java/com/l2/shots/history/RunSnapshot.java @@ -0,0 +1,16 @@ +package com.l2.shots.history; + +import java.util.List; +import java.util.Map; + +public class RunSnapshot { + public Map insumos; + public List> formulas; + + public RunSnapshot() {} + + public RunSnapshot(Map insumos, List> formulas) { + this.insumos = insumos; + this.formulas = formulas; + } +} diff --git a/src/main/java/com/l2/shots/history/RunSummary.java b/src/main/java/com/l2/shots/history/RunSummary.java new file mode 100644 index 0000000..4b219c1 --- /dev/null +++ b/src/main/java/com/l2/shots/history/RunSummary.java @@ -0,0 +1,30 @@ +package com.l2.shots.history; + +import java.time.Instant; +import java.util.UUID; + +public class RunSummary { + public UUID id; + public Instant createdAt; + public String label; + public long totalCost; + public long totalSale; + public long totalProfit; + public long totalShots; + public long totalCristalesUsed; + public long totalOreUsed; + + public RunSummary() {} + + public RunSummary(ProductionRun r) { + this.id = r.id; + this.createdAt = r.createdAt; + this.label = r.label; + this.totalCost = r.totalCost; + this.totalSale = r.totalSale; + this.totalProfit = r.totalProfit; + this.totalShots = r.totalShots; + this.totalCristalesUsed = r.totalCristalesUsed; + this.totalOreUsed = r.totalOreUsed; + } +} diff --git a/src/main/java/com/l2/shots/state/AppState.java b/src/main/java/com/l2/shots/state/AppState.java new file mode 100644 index 0000000..64268a5 --- /dev/null +++ b/src/main/java/com/l2/shots/state/AppState.java @@ -0,0 +1,59 @@ +package com.l2.shots.state; + +import java.util.List; +import java.util.Map; + +public class AppState { + + public Insumos insumos; + public List formulas; + public Map> disponibles; + + public AppState() {} + + public AppState(Insumos insumos, List formulas, Map> disponibles) { + this.insumos = insumos; + this.formulas = formulas; + this.disponibles = disponibles; + } + + public static class Insumos { + public Map cristales; + public int soulOre; + public int spiritOre; + public Map> venta; + + public Insumos() {} + + public Insumos(Map cristales, int soulOre, int spiritOre, + Map> venta) { + this.cristales = cristales; + this.soulOre = soulOre; + this.spiritOre = spiritOre; + this.venta = venta; + } + } + + public static class FormulaDto { + public String id; + public String tipo; + public String grado; + public int cristalesReq; + public Integer soulOreReq; + public Integer spiritOreReq; + public int shotsObtenidos; + + public FormulaDto() {} + + public FormulaDto(String id, String tipo, String grado, int cristalesReq, + Integer soulOreReq, Integer spiritOreReq, int shotsObtenidos) { + this.id = id; + this.tipo = tipo; + this.grado = grado; + this.cristalesReq = cristalesReq; + this.soulOreReq = soulOreReq; + this.spiritOreReq = spiritOreReq; + this.shotsObtenidos = shotsObtenidos; + } + } +} diff --git a/src/main/java/com/l2/shots/state/StateResource.java b/src/main/java/com/l2/shots/state/StateResource.java new file mode 100644 index 0000000..b0b14fd --- /dev/null +++ b/src/main/java/com/l2/shots/state/StateResource.java @@ -0,0 +1,74 @@ +package com.l2.shots.state; + +import com.l2.shots.auth.JwtCookieAuth; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.Optional; +import java.util.UUID; + +@Path("/api/state") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class StateResource { + + @Inject + StateService stateService; + + @Inject + JwtCookieAuth jwtCookieAuth; + + @GET + public Response get(@Context HttpHeaders headers) { + Optional userId = extractUserId(headers); + if (userId.isEmpty()) return Response.status(401).build(); + + Optional state = stateService.getForUser(userId.get()); + return state.map(s -> Response.ok(s).build()) + .orElse(Response.status(404).build()); + } + + @PUT + public Response put(@Context HttpHeaders headers, AppState state) { + Optional userId = extractUserId(headers); + if (userId.isEmpty()) return Response.status(401).build(); + + if (state == null || state.insumos == null || state.formulas == null || state.disponibles == null) { + return Response.status(400).entity("{\"error\":\"estado inválido\"}").build(); + } + if (state.formulas.size() > 200) { + return Response.status(400).entity("{\"error\":\"estado demasiado grande\"}").build(); + } + stateService.saveForUser(userId.get(), state); + return Response.noContent().build(); + } + + @DELETE + public Response reset(@Context HttpHeaders headers) { + Optional userId = extractUserId(headers); + if (userId.isEmpty()) return Response.status(401).build(); + + stateService.deleteForUser(userId.get()); + return Response.noContent().build(); + } + + private Optional extractUserId(HttpHeaders headers) { + Optional jwt = jwtCookieAuth.extractToken(headers); + if (jwt.isEmpty()) return Optional.empty(); + try { + return Optional.of(UUID.fromString(jwt.get().getSubject())); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/l2/shots/state/StateService.java b/src/main/java/com/l2/shots/state/StateService.java new file mode 100644 index 0000000..aef2eee --- /dev/null +++ b/src/main/java/com/l2/shots/state/StateService.java @@ -0,0 +1,48 @@ +package com.l2.shots.state; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.l2.shots.auth.UserState; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + +import java.time.Instant; +import java.util.Optional; + +@ApplicationScoped +public class StateService { + + private final ObjectMapper mapper = new ObjectMapper(); + + public Optional getForUser(java.util.UUID userId) { + UserState entity = UserState.findByUserId(userId); + if (entity == null) return Optional.empty(); + try { + return Optional.of(mapper.readValue(entity.stateJson, AppState.class)); + } catch (JsonProcessingException e) { + return Optional.empty(); + } + } + + @Transactional + public void saveForUser(java.util.UUID userId, AppState state) { + try { + String json = mapper.writeValueAsString(state); + UserState entity = UserState.findByUserId(userId); + if (entity == null) { + entity = new UserState(); + entity.userId = userId; + } + entity.stateJson = json; + entity.updatedAt = Instant.now(); + entity.persist(); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize state", e); + } + } + + @Transactional + public void deleteForUser(java.util.UUID userId) { + UserState.deleteById(userId); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..b0629b7 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,26 @@ +quarkus.http.port=8080 +quarkus.http.host=0.0.0.0 + +quarkus.application.name=shot-crafter-calculator + +# H2 file-based +quarkus.datasource.db-kind=h2 +quarkus.datasource.jdbc.url=jdbc:h2:file:./data/shots;DB_CLOSE_DELAY=-1 +quarkus.datasource.username=sa +quarkus.datasource.password= +quarkus.hibernate-orm.database.generation=update +quarkus.hibernate-orm.log.sql=false + +# JWT +mp.jwt.verify.issuer=shot-crafter-calculator +mp.jwt.verify.publickey.location=publicKey.pem +smallrye.jwt.sign.key.location=privateKey.pem + +# Cookie auth +app.auth.cookie-name=auth-token +app.auth.cookie-max-age-seconds=86400 + +# Security +quarkus.http.auth.proactive=false + +%native.quarkus.native.resources.includes=META-INF/resources/.*,publicKey.pem,privateKey.pem diff --git a/src/main/resources/privateKey.pem b/src/main/resources/privateKey.pem new file mode 100644 index 0000000..589b3b6 --- /dev/null +++ b/src/main/resources/privateKey.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4Rey1Bjlao2e9 +6AT++5zUVYZC+g3UIL29Nd/FG64+YZublZ8z9BEIG2IMm39B6XwgpyTIhVvW/lR1 +qSEcBaVPjcJVx3grx3GCbqZ+00BlJM/jwRUFMRybNZ9pCmWcWW2JTBhHPjGtfFBd +kZS3kr0htccsWbILJUJlfwSyt2+rNwGNLBJfMoJBjmWjytK5wtgOTxReaUELHRqf +hn6EukIbmyQtATDXF0Xor/MWquGrYK29oNT/R5w2oMKV4IQtDPn/Es0xkFl+nrh9 +FfgjOhOEctrf9KvPdwQIKdfhrQ/TH3iVzUxijNUOIemKoxmjf4KCz5boJHPPgSsq +A49fQ5xFAgMBAAECggEACugB51brzGxbxqwgUOfFyPVvjFruDZjgfKiJysnldAYP +5n33sw9Qy2jffT0zeMxfud5apCRBzXA2bH5VminQC8IpTIK79I9fLafsjRi7Y3Zx +zW+PQG7p3AUo9FfBWtHE1LmfEucLRqgoaNlQctWprFXsvg29psDjn0viFHfHPAax +iDe3l0ftILoUF62UEisJ8aAbd+tRPnN6uGD+f6b95+wCeRdK6WlA/ZATxie+O7A5 +u/P0ID2WdVAiANB4MVo71zMEr3fXkHWX4NerlA3MGLGYCmkN28xFpvifPh1v84OX +oNHu0v7o88xxfHPmP/Zn138Uzad8N3uKb9n8XYzdMQKBgQDZUJNW/s6eQcq83Lpb +yRjMyB+uq4M1XVTLX9wENdG3UxI0Vfv7n5WWz+Gd2telyRryrDjzGWupTm0/HsuO +BrNfX2pwwuCdrbnTtdixMRImTipRxRXiRSMSn6pFqfpt3zrt+JzD1GHAtY3BcEi5 +dvoLnTiSzGZw2pb7rwuyeVsdWQKBgQDZE5VpX2NYvdm3BwoOp/KllJxgttFVRbKs +KbfRVF1sQ68dBU/w2aUOF8dNxXzFc+nU+M8F4zz0YGbcsEhzkjKV8S/gRxiaso6c +Qqeba8/OxqeztQnHbVh6bbSFfG9i0+FXlJVqKttjitVB0QZB3gyLdkmL0frksrvL +F3UddwZ8zQKBgHTwWPjdUM30VWZf2KB/jCrWHcZeYNKckH6H7NsPIvTlbMxg4KG8 +dECdSKkrFBQQLcIcTuDx8u8+Vqc6qQqaLHfL3nkjRL9UtsRn/F0NLNkUAs3Roj8K +OR9Sb8vg9fOdxhY8TA9M//U1PTy0cU3r6g3J4qGMACwGVGzG+yJlD1SxAoGBALyp +756QX+jdwB35yTzprNNKMQtBePhSxjIpY/BUEYop3UUsu8jJcFGqSvcF4CZAUwdd +Y5hrYivGqT+/GokPlFWLNKAJSpIRBC89IyzKa+b78v8WJjSkjVSCinXFq41KNzyG +D8IhE2IVZLl6MKUIlwCSwuL5kcQ4r0yYy5nbO9E1AoGAVATm7YeZxFd3WIZwMtk1 +RkqMMGM6jZHk5aaHMR0YIsja4jSQjWu86y7y653nnxsVr08PnMESTREmEiyWNUC6 +94VxRSqc0HSUXHsE5w1ig2rRJg4fhrgDiQeabXUHuldYIc4Dyfps92QXguVLBhde +o1gonQ1xfEz2HcfnDFwytGM= +-----END PRIVATE KEY----- diff --git a/src/main/resources/publicKey.pem b/src/main/resources/publicKey.pem new file mode 100644 index 0000000..df5affa --- /dev/null +++ b/src/main/resources/publicKey.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuEXstQY5WqNnvegE/vuc +1FWGQvoN1CC9vTXfxRuuPmGbm5WfM/QRCBtiDJt/Qel8IKckyIVb1v5UdakhHAWl +T43CVcd4K8dxgm6mftNAZSTP48EVBTEcmzWfaQplnFltiUwYRz4xrXxQXZGUt5K9 +IbXHLFmyCyVCZX8EsrdvqzcBjSwSXzKCQY5lo8rSucLYDk8UXmlBCx0an4Z+hLpC +G5skLQEw1xdF6K/zFqrhq2CtvaDU/0ecNqDCleCELQz5/xLNMZBZfp64fRX4IzoT +hHLa3/Srz3cECCnX4a0P0x94lc1MYozVDiHpiqMZo3+Cgs+W6CRzz4ErKgOPX0Oc +RQIDAQAB +-----END PUBLIC KEY-----