diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/.DS_Store differ diff --git a/develop-ctf-platform-frontend3.zip b/develop-ctf-platform-frontend3.zip deleted file mode 100644 index cfdd945..0000000 Binary files a/develop-ctf-platform-frontend3.zip and /dev/null differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..0af6b17 --- /dev/null +++ b/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + Врата ADA - CTF Attack-Defence Platform + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c016c89 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4907 @@ +{ + "name": "react-vite-tailwind", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "react-vite-tailwind", + "version": "0.0.0", + "dependencies": { + "@hookform/resolvers": "^5.7.1", + "@radix-ui/react-accordion": "^1.2.20", + "@radix-ui/react-avatar": "^1.2.6", + "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-progress": "^1.1.16", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.3.7", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", + "@tanstack/react-table": "^9.1.2", + "axios": "^1.19.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.31.0", + "react": "19.2.6", + "react-dom": "19.2.6", + "react-hook-form": "^7.85.0", + "react-hot-toast": "^2.6.0", + "react-router-dom": "^7.18.2", + "recharts": "^3.10.1", + "socket.io-client": "^4.8.3", + "tailwind-merge": "^3.4.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@tailwindcss/vite": "4.1.17", + "@types/node": "22.19.17", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.1", + "tailwindcss": "4.1.17", + "typescript": "5.9.3", + "vite": "7.3.2", + "vite-plugin-singlefile": "2.3.0" + } + }, + "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.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.7.1.tgz", + "integrity": "sha512-8wS/P4UDr5sQDe4nFaV51TVyfDPrWgNIXweqG0Bs9Z5LSuzKLb+RQNPvkN2oHM5SRrJyWrVH/F+LOUcFjUyvwQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "@sinclair/typebox": ">=0.25.24", + "@standard-schema/spec": "^1.0.0", + "@typeschema/main": ">=0.13.7", + "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", + "ajv": "^8.12.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "arktype": "^2.0.0", + "ata-validator": "^1.2.0", + "class-transformer": ">=0.4.0", + "class-validator": ">=0.12.0", + "computed-types": "^1.0.0", + "effect": "^3.10.3", + "fluentvalidation-ts": "^3.0.0", + "fp-ts": "^2.7.0", + "io-ts": "^2.0.0", + "joi": "^17.0.0", + "nope-validator": ">=0.12.0", + "react-hook-form": "^7.55.0", + "superstruct": ">=0.12.0", + "typanion": "^3.3.2", + "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", + "vest": ">=3.0.0", + "yup": "^1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + }, + "@standard-schema/spec": { + "optional": true + }, + "@typeschema/main": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "ajv": { + "optional": true + }, + "ajv-errors": { + "optional": true + }, + "ajv-formats": { + "optional": true + }, + "arktype": { + "optional": true + }, + "ata-validator": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "computed-types": { + "optional": true + }, + "effect": { + "optional": true + }, + "fluentvalidation-ts": { + "optional": true + }, + "fp-ts": { + "optional": true + }, + "io-ts": { + "optional": true + }, + "joi": { + "optional": true + }, + "nope-validator": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "typanion": { + "optional": true + }, + "valibot": { + "optional": true + }, + "vest": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@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/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.20.tgz", + "integrity": "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", + "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", + "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.16.tgz", + "integrity": "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", + "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.7.tgz", + "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", + "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "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/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", + "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "tailwindcss": "4.1.17" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tanstack/react-table": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-9.1.2.tgz", + "integrity": "sha512-YQPZFJ1nIi/bjjwsPZVouABgahDcl7Gdm33CdTStUJBn0DjEVJ2uhSTVmIoWt9MVKdQziXGAsXipSzy949Hygg==", + "license": "MIT", + "dependencies": { + "@tanstack/react-store": "^0.11.0", + "@tanstack/table-core": "9.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@tanstack/react-table/node_modules/@tanstack/react-store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz", + "integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.1.2.tgz", + "integrity": "sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.11.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "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/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "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/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", + "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.47", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.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/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/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/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/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/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/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types" + ] + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "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/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "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/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/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "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==", + "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/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/goober": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", + "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/immer": { + "version": "11.1.16", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.16.tgz", + "integrity": "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "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": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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==", + "dev": true, + "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/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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/lucide-react": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", + "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/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/micromatch/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/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/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/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": "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/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/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-hook-form": { + "version": "7.85.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.85.0.tgz", + "integrity": "sha512-U2MTriFXnclmV4rOE20p2DcRFv5WEg3FIcBFOKcOLFHDVvGIMPvLTkTWefUsonmlaVy23khVDxDWym6uJVGOzw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "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/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "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/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "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/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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "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/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "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/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-singlefile": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.0.tgz", + "integrity": "sha512-DAcHzYypM0CasNLSz/WG0VdKOCxGHErfrjOoyIPiNxTPTGmO6rRD/te93n1YL/s+miXq66ipF1brMBikf99c6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.44.1", + "vite": "^5.4.11 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "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" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c4c4ca0 --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "react-vite-tailwind", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^5.7.1", + "@radix-ui/react-accordion": "^1.2.20", + "@radix-ui/react-avatar": "^1.2.6", + "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-progress": "^1.1.16", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.3.7", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", + "@tanstack/react-table": "^9.1.2", + "axios": "^1.19.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.31.0", + "react": "19.2.6", + "react-dom": "19.2.6", + "react-hook-form": "^7.85.0", + "react-hot-toast": "^2.6.0", + "react-router-dom": "^7.18.2", + "recharts": "^3.10.1", + "socket.io-client": "^4.8.3", + "tailwind-merge": "^3.4.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@tailwindcss/vite": "4.1.17", + "@types/node": "22.19.17", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.1", + "tailwindcss": "4.1.17", + "typescript": "5.9.3", + "vite": "7.3.2", + "vite-plugin-singlefile": "2.3.0" + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..efc213b --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,52 @@ +import { useEffect } from 'react'; +import { RouterProvider } from 'react-router-dom'; +import { Toaster } from 'react-hot-toast'; +import { router } from './router'; +import { useAuthStore, useUIStore } from './store'; + +function App() { + const { initialize } = useAuthStore(); + const { resolvedTheme } = useUIStore(); + + // Initialize auth on mount + useEffect(() => { + initialize(); + }, [initialize]); + + // Apply theme class to document + useEffect(() => { + document.documentElement.classList.remove('light', 'dark'); + document.documentElement.classList.add(resolvedTheme); + }, [resolvedTheme]); + + return ( + <> + + + + ); +} + +export default App; diff --git a/src/api/axios.ts b/src/api/axios.ts new file mode 100644 index 0000000..e3363f2 --- /dev/null +++ b/src/api/axios.ts @@ -0,0 +1,209 @@ +import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; +import toast from 'react-hot-toast'; +import { ApiError, TokenPair } from './types'; + +// Create axios instance with default config +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api/v1'; + +export const apiClient: AxiosInstance = axios.create({ + baseURL: API_BASE_URL, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Token storage utilities +const TOKEN_KEY = 'ada_access_token'; +const REFRESH_TOKEN_KEY = 'ada_refresh_token'; + +export const tokenStorage = { + getAccessToken: (): string | null => { + return localStorage.getItem(TOKEN_KEY); + }, + + getRefreshToken: (): string | null => { + return localStorage.getItem(REFRESH_TOKEN_KEY); + }, + + setTokens: (tokens: TokenPair): void => { + localStorage.setItem(TOKEN_KEY, tokens.accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken); + }, + + clearTokens: (): void => { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + }, + + hasTokens: (): boolean => { + return !!localStorage.getItem(TOKEN_KEY); + }, +}; + +// Flag to prevent multiple refresh requests +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; +}> = []; + +const processQueue = (error: Error | null, token: string | null = null): void => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + failedQueue = []; +}; + +// Request interceptor - add auth token +apiClient.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const token = tokenStorage.getAccessToken(); + + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; + }, + (error: AxiosError) => { + return Promise.reject(error); + } +); + +// Response interceptor - handle errors and token refresh +apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + + // Handle 401 Unauthorized - attempt token refresh + if (error.response?.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + // Wait for the refresh to complete + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }) + .then((token) => { + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${token}`; + } + return apiClient(originalRequest); + }) + .catch((err) => { + return Promise.reject(err); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = tokenStorage.getRefreshToken(); + + if (!refreshToken) { + tokenStorage.clearTokens(); + window.location.href = '/login'; + return Promise.reject(error); + } + + try { + const response = await axios.post( + `${API_BASE_URL}/auth/refresh`, + { refreshToken }, + { headers: { 'Content-Type': 'application/json' } } + ); + + const newTokens = response.data; + tokenStorage.setTokens(newTokens); + + processQueue(null, newTokens.accessToken); + + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}`; + } + + return apiClient(originalRequest); + } catch (refreshError) { + processQueue(refreshError as Error, null); + tokenStorage.clearTokens(); + + // Redirect to login + toast.error('Сессия истекла. Пожалуйста, войдите снова.'); + window.location.href = '/login'; + + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + + // Handle other errors + const errorMessage = getErrorMessage(error); + + // Show toast for client errors (except 401 which is handled above) + if (error.response?.status && error.response.status >= 400 && error.response.status !== 401) { + toast.error(errorMessage); + } + + // Handle network errors + if (!error.response) { + toast.error('Ошибка сети. Проверьте подключение к интернету.'); + } + + return Promise.reject(error); + } +); + +// Helper function to extract error message +function getErrorMessage(error: AxiosError): string { + if (error.response?.data?.message) { + return error.response.data.message; + } + + switch (error.response?.status) { + case 400: + return 'Неверный запрос'; + case 403: + return 'Доступ запрещён'; + case 404: + return 'Ресурс не найден'; + case 409: + return 'Конфликт данных'; + case 422: + return 'Ошибка валидации'; + case 429: + return 'Слишком много запросов. Попробуйте позже.'; + case 500: + return 'Внутренняя ошибка сервера'; + case 502: + return 'Сервис временно недоступен'; + case 503: + return 'Сервис на обслуживании'; + default: + return 'Произошла ошибка'; + } +} + +// Export typed request helpers +export const api = { + get: (url: string, config?: object) => + apiClient.get(url, config).then(res => res.data), + + post: (url: string, data?: object, config?: object) => + apiClient.post(url, data, config).then(res => res.data), + + put: (url: string, data?: object, config?: object) => + apiClient.put(url, data, config).then(res => res.data), + + patch: (url: string, data?: object, config?: object) => + apiClient.patch(url, data, config).then(res => res.data), + + delete: (url: string, config?: object) => + apiClient.delete(url, config).then(res => res.data), +}; + +export default apiClient; diff --git a/src/api/endpoints/admin.api.ts b/src/api/endpoints/admin.api.ts new file mode 100644 index 0000000..51071ef --- /dev/null +++ b/src/api/endpoints/admin.api.ts @@ -0,0 +1,251 @@ +import { api } from '../axios'; +import { + User, + Match, + Service, + AdminDashboard, + AdminActivityLog, + SystemAlert, + SystemConfig, + SystemHealthCheck, + AdminUserListParams, + AdminUpdateUserRequest, + AdminCreateMatchRequest, + AdminMatchControl, + AdminServiceListParams, + AdminUpdateServiceRequest, + AdminServiceReview, + MaintenanceRequest, + UpdateSystemConfigRequest, + PaginatedResponse, + PaginationParams, +} from '../types'; + +export const adminApi = { + // Dashboard + getDashboard: (): Promise => { + return api.get('/admin/dashboard'); + }, + + // User management + getUsers: ( + params?: AdminUserListParams + ): Promise> => { + return api.get>('/admin/users', { params }); + }, + + getUser: (userId: string): Promise => { + return api.get(`/admin/users/${userId}`); + }, + + updateUser: (userId: string, data: AdminUpdateUserRequest): Promise => { + return api.patch(`/admin/users/${userId}`, data); + }, + + deleteUser: (userId: string): Promise => { + return api.delete(`/admin/users/${userId}`); + }, + + banUser: ( + userId: string, + reason: string, + expiresAt?: string + ): Promise => { + return api.post(`/admin/users/${userId}/ban`, { reason, expiresAt }); + }, + + unbanUser: (userId: string): Promise => { + return api.post(`/admin/users/${userId}/unban`); + }, + + resetUserPassword: (userId: string): Promise<{ temporaryPassword: string }> => { + return api.post(`/admin/users/${userId}/reset-password`); + }, + + impersonateUser: (userId: string): Promise<{ token: string; expiresAt: string }> => { + return api.post(`/admin/users/${userId}/impersonate`); + }, + + // Match management + getMatches: ( + params?: PaginationParams & { status?: string; mode?: string } + ): Promise> => { + return api.get>('/admin/matches', { params }); + }, + + createMatch: (data: AdminCreateMatchRequest): Promise => { + return api.post('/admin/matches', data); + }, + + controlMatch: (data: AdminMatchControl): Promise => { + const { matchId, ...body } = data; + return api.post(`/admin/matches/${matchId}/control`, body); + }, + + forceEndMatch: (matchId: string, reason: string): Promise => { + return api.post(`/admin/matches/${matchId}/force-end`, { reason }); + }, + + getMatchHealth: (matchId: string): Promise<{ + status: string; + checkers: Array<{ serviceId: string; status: string; lastRun: string }>; + scorers: Array<{ status: string; queueLength: number }>; + network: { status: string; latency: number }; + }> => { + return api.get(`/admin/matches/${matchId}/health`); + }, + + // Service management + getServices: ( + params?: AdminServiceListParams + ): Promise> => { + return api.get>('/admin/services', { params }); + }, + + getPendingServices: (): Promise => { + return api.get('/admin/services/pending'); + }, + + reviewService: (data: AdminServiceReview): Promise => { + const { serviceId, ...body } = data; + return api.post(`/admin/services/${serviceId}/review`, body); + }, + + updateService: ( + serviceId: string, + data: AdminUpdateServiceRequest + ): Promise => { + return api.patch(`/admin/services/${serviceId}`, data); + }, + + deleteService: (serviceId: string): Promise => { + return api.delete(`/admin/services/${serviceId}`); + }, + + // System management + getSystemConfig: (): Promise => { + return api.get('/admin/system/config'); + }, + + updateSystemConfig: (data: UpdateSystemConfigRequest): Promise => { + return api.patch('/admin/system/config', data); + }, + + getSystemHealth: (): Promise => { + return api.get('/admin/system/health'); + }, + + setMaintenanceMode: (data: MaintenanceRequest): Promise<{ success: boolean }> => { + return api.post('/admin/system/maintenance', data); + }, + + restartService: ( + serviceName: string + ): Promise<{ success: boolean; message: string }> => { + return api.post(`/admin/system/restart/${serviceName}`); + }, + + clearCache: ( + cacheType?: 'all' | 'scoreboard' | 'sessions' | 'api' + ): Promise<{ cleared: string[] }> => { + return api.post('/admin/system/clear-cache', { cacheType }); + }, + + // Activity logs + getActivityLogs: ( + params?: PaginationParams & { + adminId?: string; + action?: string; + targetType?: string; + from?: string; + to?: string; + } + ): Promise> => { + return api.get>('/admin/activity-logs', { + params, + }); + }, + + // Alerts + getAlerts: ( + params?: { + severity?: string; + isResolved?: boolean; + limit?: number; + } + ): Promise => { + return api.get('/admin/alerts', { params }); + }, + + resolveAlert: (alertId: string): Promise => { + return api.post(`/admin/alerts/${alertId}/resolve`); + }, + + dismissAlert: (alertId: string): Promise => { + return api.delete(`/admin/alerts/${alertId}`); + }, + + // Statistics + getSystemStats: (): Promise<{ + users: { total: number; active: number; new24h: number }; + teams: { total: number; active: number }; + matches: { total: number; active: number; today: number }; + services: { total: number; pending: number }; + exercises: { total: number; completions24h: number }; + }> => { + return api.get('/admin/stats'); + }, + + getUsageStats: (period: 'day' | 'week' | 'month'): Promise<{ + apiRequests: Array<{ timestamp: string; count: number }>; + activeUsers: Array<{ timestamp: string; count: number }>; + matchesCreated: Array<{ timestamp: string; count: number }>; + flagsSubmitted: Array<{ timestamp: string; count: number }>; + }> => { + return api.get('/admin/stats/usage', { params: { period } }); + }, + + // Broadcasts + sendBroadcast: (data: { + title: string; + message: string; + type: 'info' | 'warning' | 'maintenance'; + targetRoles?: string[]; + expiresAt?: string; + }): Promise<{ sent: number }> => { + return api.post('/admin/broadcast', data); + }, + + // Backups + createBackup: ( + type: 'full' | 'database' | 'files' + ): Promise<{ + backupId: string; + status: string; + estimatedTime: number; + }> => { + return api.post('/admin/backups', { type }); + }, + + getBackups: (): Promise> => { + return api.get('/admin/backups'); + }, + + downloadBackup: (backupId: string): Promise => { + return api.get(`/admin/backups/${backupId}/download`, { + responseType: 'blob', + }); + }, + + restoreBackup: (backupId: string): Promise<{ status: string }> => { + return api.post(`/admin/backups/${backupId}/restore`); + }, +}; + +export default adminApi; diff --git a/src/api/endpoints/analytics.api.ts b/src/api/endpoints/analytics.api.ts new file mode 100644 index 0000000..9ce3fff --- /dev/null +++ b/src/api/endpoints/analytics.api.ts @@ -0,0 +1,259 @@ +import { api } from '../axios'; +import { + TeamAnalytics, + PlayerAnalytics, + MatchAnalytics, + AIRecommendation, + TeamAnalyticsParams, + PlayerAnalyticsParams, + MatchAnalyticsParams, + AnalyticsPeriod, + HeatmapData, +} from '../types'; + +export const analyticsApi = { + // Get team analytics + getTeamAnalytics: (params: TeamAnalyticsParams): Promise => { + const { teamId, ...queryParams } = params; + return api.get(`/analytics/teams/${teamId}`, { + params: queryParams, + }); + }, + + // Get player analytics + getPlayerAnalytics: (params: PlayerAnalyticsParams): Promise => { + const { userId, ...queryParams } = params; + return api.get(`/analytics/players/${userId}`, { + params: queryParams, + }); + }, + + // Get match analytics + getMatchAnalytics: (params: MatchAnalyticsParams): Promise => { + const { matchId, ...queryParams } = params; + return api.get(`/analytics/matches/${matchId}`, { + params: queryParams, + }); + }, + + // Get AI recommendations for team + getTeamRecommendations: ( + teamId: string, + limit?: number + ): Promise => { + return api.get( + `/analytics/teams/${teamId}/recommendations`, + { params: { limit } } + ); + }, + + // Get AI recommendations for player + getPlayerRecommendations: ( + userId: string, + limit?: number + ): Promise => { + return api.get( + `/analytics/players/${userId}/recommendations`, + { params: { limit } } + ); + }, + + // Get match AI insights + getMatchInsights: (matchId: string): Promise => { + return api.get(`/analytics/matches/${matchId}/insights`); + }, + + // Get attack heatmap + getAttackHeatmap: ( + matchId: string, + params?: { + teamId?: string; + round?: number; + } + ): Promise => { + return api.get(`/analytics/matches/${matchId}/heatmap/attacks`, { + params, + }); + }, + + // Get SLA heatmap + getSLAHeatmap: ( + matchId: string, + params?: { + teamId?: string; + } + ): Promise => { + return api.get(`/analytics/matches/${matchId}/heatmap/sla`, { + params, + }); + }, + + // Get team performance trends + getTeamTrends: ( + teamId: string, + period: AnalyticsPeriod + ): Promise; + trend: 'up' | 'down' | 'stable'; + changePercent: number; + }>> => { + return api.get(`/analytics/teams/${teamId}/trends`, { + params: { ...period }, + }); + }, + + // Get player skill progression + getSkillProgression: ( + userId: string, + skillId?: string + ): Promise; + }>> => { + return api.get(`/analytics/players/${userId}/skill-progression`, { + params: { skillId }, + }); + }, + + // Compare teams + compareTeams: ( + teamIds: string[], + period?: AnalyticsPeriod + ): Promise<{ + teams: Array<{ + teamId: string; + teamName: string; + }>; + metrics: Array<{ + name: string; + values: Record; + }>; + }> => { + return api.get('/analytics/compare/teams', { + params: { teamIds: teamIds.join(','), ...period }, + }); + }, + + // Compare players + comparePlayers: ( + userIds: string[], + period?: AnalyticsPeriod + ): Promise<{ + players: Array<{ + userId: string; + username: string; + }>; + metrics: Array<{ + name: string; + values: Record; + }>; + }> => { + return api.get('/analytics/compare/players', { + params: { userIds: userIds.join(','), ...period }, + }); + }, + + // Get service analytics for match + getServiceAnalytics: ( + matchId: string, + serviceId: string + ): Promise<{ + serviceId: string; + serviceName: string; + totalExploits: number; + uniqueExploiters: number; + avgExploitTime: number; + exploitsByRound: Array<{ round: number; count: number }>; + exploitsByTeam: Array<{ teamId: string; teamName: string; count: number }>; + slaByTeam: Array<{ teamId: string; teamName: string; sla: number }>; + firstBlood: { + teamId: string; + teamName: string; + round: number; + timestamp: string; + } | null; + }> => { + return api.get(`/analytics/matches/${matchId}/services/${serviceId}`); + }, + + // Get round-by-round analysis + getRoundAnalysis: ( + matchId: string, + roundNumber: number + ): Promise<{ + round: number; + summary: string; + keyEvents: Array<{ + type: string; + description: string; + impact: number; + }>; + teamPerformance: Array<{ + teamId: string; + teamName: string; + score: number; + attackSuccess: number; + defenseSuccess: number; + sla: number; + }>; + mvp: { + teamId: string; + teamName: string; + reason: string; + } | null; + }> => { + return api.get(`/analytics/matches/${matchId}/rounds/${roundNumber}/analysis`); + }, + + // Export analytics report + exportAnalytics: ( + type: 'team' | 'player' | 'match', + id: string, + format: 'pdf' | 'json' | 'csv' + ): Promise => { + return api.get(`/analytics/${type}s/${id}/export`, { + params: { format }, + responseType: 'blob', + }); + }, + + // Get benchmark data (how team/player compares to others) + getBenchmark: ( + type: 'team' | 'player', + id: string + ): Promise<{ + percentiles: Record; + comparisons: Array<{ + metric: string; + value: number; + average: number; + top10Percent: number; + percentile: number; + }>; + }> => { + return api.get(`/analytics/benchmark/${type}s/${id}`); + }, + + // Get improvement suggestions + getImprovementSuggestions: ( + type: 'team' | 'player', + id: string + ): Promise; + }>> => { + return api.get(`/analytics/${type}s/${id}/suggestions`); + }, +}; + +export default analyticsApi; diff --git a/src/api/endpoints/auth.api.ts b/src/api/endpoints/auth.api.ts new file mode 100644 index 0000000..e33bd7b --- /dev/null +++ b/src/api/endpoints/auth.api.ts @@ -0,0 +1,160 @@ +import { api, tokenStorage } from '../axios'; +import { + User, + LoginRequest, + RegisterRequest, + AuthResponse, + TokenPair, + ForgotPasswordRequest, + ResetPasswordRequest, + ChangePasswordRequest, + UpdateProfileRequest, + ApiKey, + CreateApiKeyRequest, + CreateApiKeyResponse, +} from '../types'; + +export const authApi = { + // Login user + login: async (data: LoginRequest): Promise => { + const response = await api.post('/auth/login', data); + tokenStorage.setTokens(response.tokens); + return response; + }, + + // Register new user + register: async (data: RegisterRequest): Promise => { + const response = await api.post('/auth/register', data); + tokenStorage.setTokens(response.tokens); + return response; + }, + + // Logout user + logout: async (): Promise => { + try { + await api.post('/auth/logout'); + } finally { + tokenStorage.clearTokens(); + } + }, + + // Get current user + getMe: (): Promise => { + return api.get('/auth/me'); + }, + + // Refresh access token + refreshToken: async (refreshToken: string): Promise => { + const response = await api.post('/auth/refresh', { refreshToken }); + tokenStorage.setTokens(response); + return response; + }, + + // Send forgot password email + forgotPassword: (data: ForgotPasswordRequest): Promise<{ message: string }> => { + return api.post<{ message: string }>('/auth/forgot-password', data); + }, + + // Reset password with token + resetPassword: (data: ResetPasswordRequest): Promise<{ message: string }> => { + return api.post<{ message: string }>('/auth/reset-password', data); + }, + + // Change password (authenticated) + changePassword: (data: ChangePasswordRequest): Promise<{ message: string }> => { + return api.post<{ message: string }>('/auth/change-password', data); + }, + + // Update profile + updateProfile: (data: UpdateProfileRequest): Promise => { + return api.patch('/auth/profile', data); + }, + + // Upload avatar + uploadAvatar: async (file: File): Promise<{ avatarUrl: string }> => { + const formData = new FormData(); + formData.append('avatar', file); + return api.post<{ avatarUrl: string }>('/auth/avatar', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }, + + // Delete avatar + deleteAvatar: (): Promise => { + return api.delete('/auth/avatar'); + }, + + // Verify email + verifyEmail: (token: string): Promise<{ message: string }> => { + return api.post<{ message: string }>('/auth/verify-email', { token }); + }, + + // Resend verification email + resendVerification: (): Promise<{ message: string }> => { + return api.post<{ message: string }>('/auth/resend-verification'); + }, + + // Enable two-factor authentication + enableTwoFactor: (): Promise<{ qrCode: string; secret: string }> => { + return api.post<{ qrCode: string; secret: string }>('/auth/2fa/enable'); + }, + + // Verify and complete 2FA setup + verifyTwoFactor: (code: string): Promise<{ backupCodes: string[] }> => { + return api.post<{ backupCodes: string[] }>('/auth/2fa/verify', { code }); + }, + + // Disable two-factor authentication + disableTwoFactor: (password: string): Promise<{ message: string }> => { + return api.post<{ message: string }>('/auth/2fa/disable', { password }); + }, + + // Get API keys + getApiKeys: (): Promise => { + return api.get('/auth/api-keys'); + }, + + // Create API key + createApiKey: (data: CreateApiKeyRequest): Promise => { + return api.post('/auth/api-keys', data); + }, + + // Delete API key + deleteApiKey: (keyId: string): Promise => { + return api.delete(`/auth/api-keys/${keyId}`); + }, + + // Check if username is available + checkUsername: (username: string): Promise<{ available: boolean }> => { + return api.get<{ available: boolean }>(`/auth/check-username/${username}`); + }, + + // Check if email is available + checkEmail: (email: string): Promise<{ available: boolean }> => { + return api.get<{ available: boolean }>(`/auth/check-email/${encodeURIComponent(email)}`); + }, + + // Get active sessions + getSessions: (): Promise> => { + return api.get('/auth/sessions'); + }, + + // Revoke session + revokeSession: (sessionId: string): Promise => { + return api.delete(`/auth/sessions/${sessionId}`); + }, + + // Revoke all other sessions + revokeAllSessions: (): Promise => { + return api.post('/auth/sessions/revoke-all'); + }, +}; + +export default authApi; diff --git a/src/api/endpoints/flags.api.ts b/src/api/endpoints/flags.api.ts new file mode 100644 index 0000000..3cd1686 --- /dev/null +++ b/src/api/endpoints/flags.api.ts @@ -0,0 +1,178 @@ +import { api } from '../axios'; +import { + Flag, + FlagSubmission, + FlagStats, + FlagSubmissionStats, + SubmitFlagRequest, + SubmitFlagResponse, + BulkSubmitFlagsRequest, + BulkSubmitFlagsResponse, + FlagHistoryParams, + PaginatedResponse, +} from '../types'; + +export const flagsApi = { + // Submit a flag + submitFlag: (data: SubmitFlagRequest): Promise => { + return api.post('/flags/submit', data); + }, + + // Bulk submit flags + bulkSubmitFlags: (data: BulkSubmitFlagsRequest): Promise => { + return api.post('/flags/submit/bulk', data); + }, + + // Get flag submission history for current team + getFlagHistory: ( + params: FlagHistoryParams + ): Promise> => { + const { matchId, ...queryParams } = params; + return api.get>( + `/matches/${matchId}/flags/history`, + { params: queryParams } + ); + }, + + // Get flag stats for team + getFlagStats: (matchId: string, teamId: string): Promise => { + return api.get(`/matches/${matchId}/teams/${teamId}/flag-stats`); + }, + + // Get submission stats + getSubmissionStats: (matchId: string, teamId: string): Promise => { + return api.get( + `/matches/${matchId}/teams/${teamId}/submission-stats` + ); + }, + + // Get captured flags (attacks made by team) + getCapturedFlags: ( + matchId: string, + teamId: string, + params?: { + serviceId?: string; + round?: number; + limit?: number; + offset?: number; + } + ): Promise> => { + return api.get>( + `/matches/${matchId}/teams/${teamId}/flags/captured`, + { params } + ); + }, + + // Get lost flags (flags stolen from team) + getLostFlags: ( + matchId: string, + teamId: string, + params?: { + serviceId?: string; + round?: number; + limit?: number; + offset?: number; + } + ): Promise> => { + return api.get>( + `/matches/${matchId}/teams/${teamId}/flags/lost`, + { params } + ); + }, + + // Get recent submissions (live feed) + getRecentSubmissions: ( + matchId: string, + limit?: number + ): Promise => { + return api.get(`/matches/${matchId}/flags/recent`, { + params: { limit }, + }); + }, + + // Get flag distribution by service + getFlagDistributionByService: ( + matchId: string + ): Promise> => { + return api.get(`/matches/${matchId}/flags/distribution/by-service`); + }, + + // Get flag distribution by team + getFlagDistributionByTeam: ( + matchId: string + ): Promise> => { + return api.get(`/matches/${matchId}/flags/distribution/by-team`); + }, + + // Get flag timeline (for visualization) + getFlagTimeline: ( + matchId: string, + params?: { + teamId?: string; + serviceId?: string; + fromRound?: number; + toRound?: number; + } + ): Promise> => { + return api.get(`/matches/${matchId}/flags/timeline`, { params }); + }, + + // Get first blood info + getFirstBlood: (matchId: string): Promise<{ + serviceId: string; + serviceName: string; + attackerTeamId: string; + attackerTeamName: string; + defenderTeamId: string; + defenderTeamName: string; + round: number; + timestamp: string; + timeFromStart: number; // seconds + } | null> => { + return api.get(`/matches/${matchId}/flags/first-blood`); + }, + + // Validate flag format (client-side check before submission) + validateFlagFormat: (matchId: string, flag: string): Promise<{ + valid: boolean; + message: string; + }> => { + return api.post(`/matches/${matchId}/flags/validate-format`, { flag }); + }, + + // Get submission rate limits + getRateLimits: (matchId: string): Promise<{ + perSecond: number; + perMinute: number; + currentUsage: { + lastSecond: number; + lastMinute: number; + }; + resetAt: string; + }> => { + return api.get(`/matches/${matchId}/flags/rate-limits`); + }, +}; + +export default flagsApi; diff --git a/src/api/endpoints/index.ts b/src/api/endpoints/index.ts new file mode 100644 index 0000000..813df39 --- /dev/null +++ b/src/api/endpoints/index.ts @@ -0,0 +1,19 @@ +// Export all API modules +export { authApi } from './auth.api'; +export { usersApi } from './users.api'; +export { teamsApi } from './teams.api'; +export { matchesApi } from './matches.api'; +export { roundsApi } from './rounds.api'; +export { servicesApi } from './services.api'; +export { scoreboardApi } from './scoreboard.api'; +export { flagsApi } from './flags.api'; +export { logsApi } from './logs.api'; +export { trainingApi } from './training.api'; +export { analyticsApi } from './analytics.api'; +export { adminApi } from './admin.api'; +export { notificationsApi } from './notifications.api'; +export { seasonsApi } from './seasons.api'; + +// Re-export types for convenience +export type { UserProfile, UserActivity, LeaderboardUser } from './users.api'; +export type { MatchSummary } from './matches.api'; diff --git a/src/api/endpoints/logs.api.ts b/src/api/endpoints/logs.api.ts new file mode 100644 index 0000000..07e5efc --- /dev/null +++ b/src/api/endpoints/logs.api.ts @@ -0,0 +1,220 @@ +import { api } from '../axios'; +import { + LogEntry, + LogFilterParams, + ReplayData, + ReplayParams, + EventTimelineParams, + PaginatedResponse, + EventType, + LogLevel, +} from '../types'; +import type { LogTimelineEvent } from '../types'; + +export const logsApi = { + // Get logs with filtering + getLogs: (params: LogFilterParams): Promise> => { + const { matchId, ...queryParams } = params; + return api.get>(`/matches/${matchId}/logs`, { + params: queryParams, + }); + }, + + // Get event timeline + getEventTimeline: ( + params: EventTimelineParams + ): Promise => { + const { matchId, ...queryParams } = params; + return api.get(`/matches/${matchId}/timeline`, { + params: queryParams, + }); + }, + + // Get replay data + getReplay: (params: ReplayParams): Promise => { + const { matchId, ...queryParams } = params; + return api.get(`/matches/${matchId}/replay`, { + params: queryParams, + }); + }, + + // Get replay frame at specific time + getReplayFrame: ( + matchId: string, + timestamp: number + ): Promise<{ + scoreboard: Array<{ + teamId: string; + teamName: string; + position: number; + score: number; + }>; + events: LogTimelineEvent[]; + serviceStatuses: Array<{ + teamId: string; + serviceId: string; + status: string; + }>; + }> => { + return api.get(`/matches/${matchId}/replay/frame`, { + params: { timestamp }, + }); + }, + + // Get key moments + getKeyMoments: (matchId: string): Promise> => { + return api.get(`/matches/${matchId}/replay/key-moments`); + }, + + // Stream logs (SSE) + streamLogs: ( + matchId: string, + filters: { + levels?: LogLevel[]; + types?: EventType[]; + teamId?: string; + serviceId?: string; + }, + onMessage: (log: LogEntry) => void, + onError?: (error: Event) => void + ): EventSource => { + const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001/api/v1'; + const params = new URLSearchParams(); + + if (filters.levels) params.append('levels', filters.levels.join(',')); + if (filters.types) params.append('types', filters.types.join(',')); + if (filters.teamId) params.append('teamId', filters.teamId); + if (filters.serviceId) params.append('serviceId', filters.serviceId); + + const url = `${baseUrl}/matches/${matchId}/logs/stream?${params.toString()}`; + const eventSource = new EventSource(url, { withCredentials: true }); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as LogEntry; + onMessage(data); + } catch (error) { + console.error('Error parsing log entry:', error); + } + }; + + if (onError) { + eventSource.onerror = onError; + } + + return eventSource; + }, + + // Get log statistics + getLogStats: (matchId: string): Promise<{ + total: number; + byLevel: Record; + byType: Record; + byTeam: Record; + byService: Record; + perRound: Array<{ round: number; count: number }>; + }> => { + return api.get(`/matches/${matchId}/logs/stats`); + }, + + // Search logs + searchLogs: ( + matchId: string, + query: string, + params?: { + limit?: number; + offset?: number; + } + ): Promise> => { + return api.get>(`/matches/${matchId}/logs/search`, { + params: { query, ...params }, + }); + }, + + // Export logs + exportLogs: ( + matchId: string, + format: 'json' | 'csv' | 'txt', + filters?: Partial + ): Promise => { + return api.get(`/matches/${matchId}/logs/export`, { + params: { format, ...filters }, + responseType: 'blob', + }); + }, + + // Get attack log (filtered for attack events) + getAttackLog: ( + matchId: string, + params?: { + attackerTeamId?: string; + defenderTeamId?: string; + serviceId?: string; + round?: number; + limit?: number; + offset?: number; + } + ): Promise> => { + return api.get>(`/matches/${matchId}/logs/attacks`, { + params, + }); + }, + + // Get service check log + getServiceCheckLog: ( + matchId: string, + params?: { + teamId?: string; + serviceId?: string; + result?: string; + round?: number; + limit?: number; + offset?: number; + } + ): Promise> => { + return api.get>( + `/matches/${matchId}/logs/service-checks`, + { params } + ); + }, + + // Get score change log + getScoreChangeLog: ( + matchId: string, + params?: { + teamId?: string; + round?: number; + limit?: number; + offset?: number; + } + ): Promise> => { + return api.get>( + `/matches/${matchId}/logs/score-changes`, + { params } + ); + }, + + // Bookmark log entry + bookmarkLogEntry: (logId: string, note?: string): Promise => { + return api.post(`/logs/${logId}/bookmark`, { note }); + }, + + // Get bookmarked logs + getBookmarkedLogs: (matchId: string): Promise => { + return api.get(`/matches/${matchId}/logs/bookmarked`); + }, + + // Remove bookmark + removeBookmark: (logId: string): Promise => { + return api.delete(`/logs/${logId}/bookmark`); + }, +}; + +export default logsApi; diff --git a/src/api/endpoints/matches.api.ts b/src/api/endpoints/matches.api.ts new file mode 100644 index 0000000..d4bdefa --- /dev/null +++ b/src/api/endpoints/matches.api.ts @@ -0,0 +1,193 @@ +import { api } from '../axios'; +import { + Match, + MatchResult, + MatchTeam, + CreateMatchRequest, + UpdateMatchRequest, + MatchListParams, + JoinMatchRequest, + PaginatedResponse, + PaginationParams, +} from '../types'; + +export interface MatchSummary { + id: string; + title: string; + mode: string; + status: string; + teamsCount: number; + maxTeams: number; + currentRound: number; + totalRounds: number; + scheduledAt: string | null; + startedAt: string | null; + isRanked: boolean; +} + +export const matchesApi = { + // Create a new match + createMatch: (data: CreateMatchRequest): Promise => { + return api.post('/matches', data); + }, + + // Get match by ID + getMatch: (matchId: string): Promise => { + return api.get(`/matches/${matchId}`); + }, + + // Update match + updateMatch: (matchId: string, data: UpdateMatchRequest): Promise => { + return api.patch(`/matches/${matchId}`, data); + }, + + // Delete match + deleteMatch: (matchId: string): Promise => { + return api.delete(`/matches/${matchId}`); + }, + + // List matches + listMatches: ( + params?: MatchListParams & PaginationParams + ): Promise> => { + return api.get>('/matches', { params }); + }, + + // Get active matches + getActiveMatches: (): Promise => { + return api.get('/matches/active'); + }, + + // Get upcoming matches + getUpcomingMatches: ( + params?: PaginationParams + ): Promise> => { + return api.get>('/matches/upcoming', { + params, + }); + }, + + // Get past matches + getPastMatches: ( + params?: PaginationParams + ): Promise> => { + return api.get>('/matches/past', { + params, + }); + }, + + // Join match with team + joinMatch: (matchId: string, data: JoinMatchRequest): Promise => { + return api.post(`/matches/${matchId}/join`, data); + }, + + // Leave match + leaveMatch: (matchId: string, teamId: string): Promise => { + return api.post(`/matches/${matchId}/leave`, { teamId }); + }, + + // Set team ready status + setTeamReady: ( + matchId: string, + teamId: string, + isReady: boolean + ): Promise => { + return api.post(`/matches/${matchId}/teams/${teamId}/ready`, { + isReady, + }); + }, + + // Get match teams + getMatchTeams: (matchId: string): Promise => { + return api.get(`/matches/${matchId}/teams`); + }, + + // Start match (organizer/admin) + startMatch: (matchId: string): Promise => { + return api.post(`/matches/${matchId}/start`); + }, + + // Stop match (organizer/admin) + stopMatch: (matchId: string, reason?: string): Promise => { + return api.post(`/matches/${matchId}/stop`, { reason }); + }, + + // Pause match + pauseMatch: (matchId: string): Promise => { + return api.post(`/matches/${matchId}/pause`); + }, + + // Resume match + resumeMatch: (matchId: string): Promise => { + return api.post(`/matches/${matchId}/resume`); + }, + + // Get match results + getMatchResults: (matchId: string): Promise => { + return api.get(`/matches/${matchId}/results`); + }, + + // Get match network config (for teams in match) + getNetworkConfig: (matchId: string, teamId: string): Promise<{ + ip: string; + subnet: string; + vpnConfig: string | null; + instructions: string; + }> => { + return api.get(`/matches/${matchId}/teams/${teamId}/network`); + }, + + // Download VPN config + downloadVpnConfig: (matchId: string, teamId: string): Promise => { + return api.get(`/matches/${matchId}/teams/${teamId}/vpn-config`, { + responseType: 'blob', + }); + }, + + // Get match services + getMatchServices: (matchId: string): Promise> => { + return api.get(`/matches/${matchId}/services`); + }, + + // Get featured matches + getFeaturedMatches: (): Promise => { + return api.get('/matches/featured'); + }, + + // Clone match (create new from existing config) + cloneMatch: (matchId: string): Promise => { + return api.post(`/matches/${matchId}/clone`); + }, + + // Get match statistics overview + getMatchStats: (matchId: string): Promise<{ + totalFlags: number; + totalAttacks: number; + successfulAttacks: number; + avgSLA: number; + duration: number; + roundsCompleted: number; + }> => { + return api.get(`/matches/${matchId}/stats`); + }, + + // Export match data + exportMatch: ( + matchId: string, + format: 'json' | 'csv' + ): Promise => { + return api.get(`/matches/${matchId}/export`, { + params: { format }, + responseType: 'blob', + }); + }, +}; + +export default matchesApi; diff --git a/src/api/endpoints/notifications.api.ts b/src/api/endpoints/notifications.api.ts new file mode 100644 index 0000000..4a1e22b --- /dev/null +++ b/src/api/endpoints/notifications.api.ts @@ -0,0 +1,125 @@ +import { api } from '../axios'; +import { + Notification, + NotificationGroup, + NotificationStats, + NotificationPreferences, + GetNotificationsParams, + MarkNotificationsRequest, + PaginatedResponse, +} from '../types'; + +export const notificationsApi = { + // Get notifications + getNotifications: ( + params?: GetNotificationsParams + ): Promise> => { + return api.get>('/notifications', { params }); + }, + + // Get notifications grouped by date + getNotificationsGrouped: ( + params?: GetNotificationsParams + ): Promise => { + return api.get('/notifications/grouped', { params }); + }, + + // Get single notification + getNotification: (notificationId: string): Promise => { + return api.get(`/notifications/${notificationId}`); + }, + + // Get unread count + getUnreadCount: (): Promise<{ count: number }> => { + return api.get<{ count: number }>('/notifications/unread-count'); + }, + + // Get notification stats + getStats: (): Promise => { + return api.get('/notifications/stats'); + }, + + // Mark notification as read + markAsRead: (notificationId: string): Promise => { + return api.post(`/notifications/${notificationId}/read`); + }, + + // Mark notification as unread + markAsUnread: (notificationId: string): Promise => { + return api.post(`/notifications/${notificationId}/unread`); + }, + + // Mark multiple notifications + markMultiple: (data: MarkNotificationsRequest): Promise<{ updated: number }> => { + return api.post<{ updated: number }>('/notifications/mark', data); + }, + + // Mark all as read + markAllAsRead: (): Promise<{ updated: number }> => { + return api.post<{ updated: number }>('/notifications/mark-all-read'); + }, + + // Archive notification + archiveNotification: (notificationId: string): Promise => { + return api.post(`/notifications/${notificationId}/archive`); + }, + + // Unarchive notification + unarchiveNotification: (notificationId: string): Promise => { + return api.post(`/notifications/${notificationId}/unarchive`); + }, + + // Delete notification + deleteNotification: (notificationId: string): Promise => { + return api.delete(`/notifications/${notificationId}`); + }, + + // Delete multiple notifications + deleteMultiple: (notificationIds: string[]): Promise<{ deleted: number }> => { + return api.post<{ deleted: number }>('/notifications/delete', { + notificationIds, + }); + }, + + // Clear all notifications + clearAll: (options?: { archived?: boolean }): Promise<{ deleted: number }> => { + return api.post<{ deleted: number }>('/notifications/clear', options); + }, + + // Get notification preferences + getPreferences: (): Promise => { + return api.get('/notifications/preferences'); + }, + + // Update notification preferences + updatePreferences: ( + data: Partial + ): Promise => { + return api.patch('/notifications/preferences', data); + }, + + // Subscribe to push notifications + subscribePush: (subscription: PushSubscriptionJSON): Promise<{ success: boolean }> => { + return api.post('/notifications/push/subscribe', { subscription }); + }, + + // Unsubscribe from push notifications + unsubscribePush: (): Promise => { + return api.post('/notifications/push/unsubscribe'); + }, + + // Test notification + sendTestNotification: (): Promise<{ success: boolean }> => { + return api.post('/notifications/test'); + }, + + // Get notification actions + performAction: ( + notificationId: string, + action: string + ): Promise<{ success: boolean; redirectUrl?: string }> => { + return api.post(`/notifications/${notificationId}/action`, { action }); + }, +}; + +export default notificationsApi; diff --git a/src/api/endpoints/rounds.api.ts b/src/api/endpoints/rounds.api.ts new file mode 100644 index 0000000..edcbaf9 --- /dev/null +++ b/src/api/endpoints/rounds.api.ts @@ -0,0 +1,148 @@ +import { api } from '../axios'; +import { + Round, + RoundEvent, + RoundSummary, + RoundTimeline, + ServiceCheck, + RoundEventsParams, + PaginatedResponse, +} from '../types'; + +export const roundsApi = { + // Get round by ID + getRound: (roundId: string): Promise => { + return api.get(`/rounds/${roundId}`); + }, + + // Get round by match and number + getRoundByNumber: (matchId: string, roundNumber: number): Promise => { + return api.get(`/matches/${matchId}/rounds/${roundNumber}`); + }, + + // List rounds for a match + listRounds: (matchId: string): Promise => { + return api.get(`/matches/${matchId}/rounds`); + }, + + // Get current round for a match + getCurrentRound: (matchId: string): Promise => { + return api.get(`/matches/${matchId}/rounds/current`); + }, + + // Get round events + getRoundEvents: ( + params: RoundEventsParams + ): Promise> => { + const { roundId, ...queryParams } = params; + return api.get>( + `/rounds/${roundId}/events`, + { params: queryParams } + ); + }, + + // Get round summary + getRoundSummary: (matchId: string, roundNumber: number): Promise => { + return api.get(`/matches/${matchId}/rounds/${roundNumber}/summary`); + }, + + // Get round timeline + getRoundTimeline: (matchId: string, roundNumber: number): Promise => { + return api.get( + `/matches/${matchId}/rounds/${roundNumber}/timeline` + ); + }, + + // Get service checks for a round + getServiceChecks: ( + roundId: string, + params?: { + teamId?: string; + serviceId?: string; + result?: string; + } + ): Promise => { + return api.get(`/rounds/${roundId}/checks`, { params }); + }, + + // Get round statistics + getRoundStats: (roundId: string): Promise<{ + flagsGenerated: number; + flagsCaptured: number; + flagsExpired: number; + avgSLA: number; + totalChecks: number; + successfulChecks: number; + failedChecks: number; + topAttacker: { teamId: string; teamName: string; count: number } | null; + topDefender: { teamId: string; teamName: string; count: number } | null; + mostAttackedService: { serviceId: string; serviceName: string; count: number } | null; + }> => { + return api.get(`/rounds/${roundId}/stats`); + }, + + // Get rounds comparison (for analytics) + getRoundsComparison: ( + matchId: string, + rounds: number[] + ): Promise> => { + return api.get(`/matches/${matchId}/rounds/compare`, { + params: { rounds: rounds.join(',') }, + }); + }, + + // Get round events stream (for live view) + streamRoundEvents: ( + matchId: string, + callback: (event: RoundEvent) => void + ): EventSource => { + const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001/api/v1'; + const eventSource = new EventSource( + `${baseUrl}/matches/${matchId}/rounds/stream`, + { withCredentials: true } + ); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as RoundEvent; + callback(data); + } catch (error) { + console.error('Error parsing round event:', error); + } + }; + + eventSource.onerror = (error) => { + console.error('EventSource error:', error); + }; + + return eventSource; + }, + + // Get flag distribution for round + getFlagDistribution: (roundId: string): Promise<{ + byService: Array<{ + serviceId: string; + serviceName: string; + generated: number; + captured: number; + expired: number; + }>; + byTeam: Array<{ + teamId: string; + teamName: string; + captured: number; + lost: number; + }>; + }> => { + return api.get(`/rounds/${roundId}/flag-distribution`); + }, +}; + +export default roundsApi; diff --git a/src/api/endpoints/scoreboard.api.ts b/src/api/endpoints/scoreboard.api.ts new file mode 100644 index 0000000..a748a7d --- /dev/null +++ b/src/api/endpoints/scoreboard.api.ts @@ -0,0 +1,182 @@ +import { api } from '../axios'; +import { + ScoreboardData, + ScoreboardEntry, + ScoreHistory, + TeamScore, + ScoreBreakdown, + ScoreboardParams, + ScoreHistoryParams, + LeaderboardEntry, + SeasonLeaderboard, + PaginatedResponse, +} from '../types'; + +export const scoreboardApi = { + // Get full scoreboard for a match + getScoreboard: (params: ScoreboardParams): Promise => { + const { matchId, ...queryParams } = params; + return api.get(`/matches/${matchId}/scoreboard`, { + params: queryParams, + }); + }, + + // Get scoreboard entry for specific team + getTeamScore: (matchId: string, teamId: string): Promise => { + return api.get(`/matches/${matchId}/scoreboard/teams/${teamId}`); + }, + + // Get score history for match + getScoreHistory: (params: ScoreHistoryParams): Promise => { + const { matchId, ...queryParams } = params; + return api.get(`/matches/${matchId}/scoreboard/history`, { + params: queryParams, + }); + }, + + // Get score history for specific team + getTeamScoreHistory: ( + matchId: string, + teamId: string, + params?: { + fromRound?: number; + toRound?: number; + } + ): Promise => { + return api.get( + `/matches/${matchId}/scoreboard/teams/${teamId}/history`, + { params } + ); + }, + + // Get score breakdown for team + getScoreBreakdown: (matchId: string, teamId: string): Promise => { + return api.get( + `/matches/${matchId}/scoreboard/teams/${teamId}/breakdown` + ); + }, + + // Get scoreboard for specific round + getRoundScoreboard: ( + matchId: string, + roundNumber: number + ): Promise => { + return api.get( + `/matches/${matchId}/rounds/${roundNumber}/scoreboard` + ); + }, + + // Get frozen scoreboard (last public state before freeze) + getFrozenScoreboard: (matchId: string): Promise => { + return api.get(`/matches/${matchId}/scoreboard/frozen`); + }, + + // Get final scoreboard (unfrozen, after match end) + getFinalScoreboard: (matchId: string): Promise => { + return api.get(`/matches/${matchId}/scoreboard/final`); + }, + + // Get live scoreboard updates (polling fallback) + pollScoreboard: ( + matchId: string, + lastUpdate?: string + ): Promise<{ + hasChanges: boolean; + scoreboard: ScoreboardData | null; + timestamp: string; + }> => { + return api.get(`/matches/${matchId}/scoreboard/poll`, { + params: { lastUpdate }, + }); + }, + + // Get position changes + getPositionChanges: (matchId: string): Promise; + }>> => { + return api.get(`/matches/${matchId}/scoreboard/position-changes`); + }, + + // Get score comparison between teams + getTeamsComparison: ( + matchId: string, + teamIds: string[] + ): Promise; + }>> => { + return api.get(`/matches/${matchId}/scoreboard/compare`, { + params: { teamIds: teamIds.join(',') }, + }); + }, + + // Global leaderboard + getGlobalLeaderboard: (params?: { + period?: 'week' | 'month' | 'season' | 'all_time'; + page?: number; + pageSize?: number; + }): Promise> => { + return api.get>('/leaderboard', { + params, + }); + }, + + // Season leaderboard + getSeasonLeaderboard: (seasonId: string): Promise => { + return api.get(`/seasons/${seasonId}/leaderboard`); + }, + + // Get top teams + getTopTeams: (limit?: number): Promise => { + return api.get('/leaderboard/top', { + params: { limit }, + }); + }, + + // Get team ranking + getTeamRanking: (teamId: string): Promise<{ + globalRank: number; + seasonRank: number | null; + rating: number; + percentile: number; + }> => { + return api.get(`/teams/${teamId}/ranking`); + }, + + // Export scoreboard + exportScoreboard: ( + matchId: string, + format: 'json' | 'csv' | 'pdf' + ): Promise => { + return api.get(`/matches/${matchId}/scoreboard/export`, { + params: { format }, + responseType: 'blob', + }); + }, + + // Get service status matrix + getServiceStatusMatrix: (matchId: string): Promise<{ + services: Array<{ id: string; name: string }>; + teams: Array<{ id: string; name: string }>; + matrix: Record>; + }> => { + return api.get(`/matches/${matchId}/scoreboard/service-matrix`); + }, +}; + +export default scoreboardApi; diff --git a/src/api/endpoints/seasons.api.ts b/src/api/endpoints/seasons.api.ts new file mode 100644 index 0000000..364cb9c --- /dev/null +++ b/src/api/endpoints/seasons.api.ts @@ -0,0 +1,159 @@ +import { api } from '../axios'; +import { + Season, + League, + SeasonSchedule, + SeasonStanding, + SeasonRegistration, + RegisterForSeasonRequest, + SeasonListParams, + LeagueListParams, + PaginatedResponse, + PaginationParams, +} from '../types'; + +export const seasonsApi = { + // Leagues + getLeagues: (params?: LeagueListParams): Promise => { + return api.get('/leagues', { params }); + }, + + getLeague: (leagueId: string): Promise => { + return api.get(`/leagues/${leagueId}`); + }, + + getLeagueBySlug: (slug: string): Promise => { + return api.get(`/leagues/slug/${slug}`); + }, + + // Seasons + getSeasons: ( + params?: SeasonListParams & PaginationParams + ): Promise> => { + return api.get>('/seasons', { params }); + }, + + getSeason: (seasonId: string): Promise => { + return api.get(`/seasons/${seasonId}`); + }, + + getSeasonBySlug: (slug: string): Promise => { + return api.get(`/seasons/slug/${slug}`); + }, + + getCurrentSeason: (leagueId?: string): Promise => { + return api.get('/seasons/current', { + params: { leagueId }, + }); + }, + + // Season schedule + getSeasonSchedule: (seasonId: string): Promise => { + return api.get(`/seasons/${seasonId}/schedule`); + }, + + // Standings + getSeasonStandings: (seasonId: string): Promise => { + return api.get(`/seasons/${seasonId}/standings`); + }, + + getTeamStanding: (seasonId: string, teamId: string): Promise => { + return api.get(`/seasons/${seasonId}/standings/${teamId}`); + }, + + // Registration + registerForSeason: (data: RegisterForSeasonRequest): Promise => { + return api.post('/seasons/register', data); + }, + + withdrawFromSeason: (seasonId: string, teamId: string): Promise => { + return api.post(`/seasons/${seasonId}/withdraw`, { teamId }); + }, + + getMyRegistrations: (): Promise => { + return api.get('/seasons/my-registrations'); + }, + + getSeasonRegistrations: (seasonId: string): Promise => { + return api.get(`/seasons/${seasonId}/registrations`); + }, + + // Season matches + getSeasonMatches: ( + seasonId: string, + params?: { week?: number; teamId?: string } + ): Promise> => { + return api.get(`/seasons/${seasonId}/matches`, { params }); + }, + + // Season stats + getSeasonStats: (seasonId: string): Promise<{ + teamsCount: number; + matchesPlayed: number; + matchesRemaining: number; + totalFlags: number; + avgScore: number; + topScorer: { teamId: string; teamName: string; score: number }; + bestDefense: { teamId: string; teamName: string; sla: number }; + }> => { + return api.get(`/seasons/${seasonId}/stats`); + }, + + // Season history for team + getTeamSeasonHistory: ( + teamId: string + ): Promise> => { + return api.get(`/teams/${teamId}/season-history`); + }, + + // Upcoming season events + getUpcomingEvents: ( + seasonId: string + ): Promise; + }>> => { + return api.get(`/seasons/${seasonId}/events`); + }, + + // Season rules + getSeasonRules: (seasonId: string): Promise => { + return api.get(`/seasons/${seasonId}/rules`); + }, + + // Prizes + getSeasonPrizes: (seasonId: string): Promise> => { + return api.get(`/seasons/${seasonId}/prizes`); + }, +}; + +export default seasonsApi; diff --git a/src/api/endpoints/services.api.ts b/src/api/endpoints/services.api.ts new file mode 100644 index 0000000..08726d5 --- /dev/null +++ b/src/api/endpoints/services.api.ts @@ -0,0 +1,230 @@ +import { api } from '../axios'; +import { + Service, + ServiceReview, + ServiceVersion, + ServiceValidation, + ServiceSearchParams, + CreateReviewRequest, + UploadServiceRequest, + UpdateServiceRequest, + PaginatedResponse, + PaginationParams, + ServiceCategory, + ServiceDifficulty, +} from '../types'; + +export const servicesApi = { + // List services + listServices: ( + params?: ServiceSearchParams & PaginationParams + ): Promise> => { + return api.get>('/services', { params }); + }, + + // Get service by ID + getService: (serviceId: string): Promise => { + return api.get(`/services/${serviceId}`); + }, + + // Get service by slug + getServiceBySlug: (slug: string): Promise => { + return api.get(`/services/slug/${slug}`); + }, + + // Upload new service + uploadService: (data: UploadServiceRequest): Promise => { + return api.post('/services', data); + }, + + // Update service + updateService: (serviceId: string, data: UpdateServiceRequest): Promise => { + return api.patch(`/services/${serviceId}`, data); + }, + + // Delete service + deleteService: (serviceId: string): Promise => { + return api.delete(`/services/${serviceId}`); + }, + + // Upload service files (Docker images, checker, etc.) + uploadServiceFiles: async ( + serviceId: string, + files: { + serviceImage?: File; + checkerImage?: File; + additionalFiles?: File[]; + } + ): Promise<{ status: string; urls: Record }> => { + const formData = new FormData(); + + if (files.serviceImage) { + formData.append('serviceImage', files.serviceImage); + } + if (files.checkerImage) { + formData.append('checkerImage', files.checkerImage); + } + if (files.additionalFiles) { + files.additionalFiles.forEach((file, index) => { + formData.append(`additionalFile_${index}`, file); + }); + } + + return api.post(`/services/${serviceId}/files`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }, + + // Validate service + validateService: (serviceId: string): Promise => { + return api.post(`/services/${serviceId}/validate`); + }, + + // Get validation status + getValidationStatus: (serviceId: string): Promise => { + return api.get(`/services/${serviceId}/validation`); + }, + + // Get validation history + getValidationHistory: (serviceId: string): Promise => { + return api.get(`/services/${serviceId}/validation/history`); + }, + + // Submit service for review + submitForReview: (serviceId: string): Promise => { + return api.post(`/services/${serviceId}/submit-review`); + }, + + // Publish service + publishService: (serviceId: string): Promise => { + return api.post(`/services/${serviceId}/publish`); + }, + + // Deprecate service + deprecateService: (serviceId: string, reason: string): Promise => { + return api.post(`/services/${serviceId}/deprecate`, { reason }); + }, + + // Get service versions + getServiceVersions: (serviceId: string): Promise => { + return api.get(`/services/${serviceId}/versions`); + }, + + // Create new version + createVersion: ( + serviceId: string, + data: { version: string; changelog: string } + ): Promise => { + return api.post(`/services/${serviceId}/versions`, data); + }, + + // Get service reviews + getServiceReviews: ( + serviceId: string, + params?: PaginationParams + ): Promise> => { + return api.get>( + `/services/${serviceId}/reviews`, + { params } + ); + }, + + // Create review + createReview: (serviceId: string, data: CreateReviewRequest): Promise => { + return api.post(`/services/${serviceId}/reviews`, data); + }, + + // Update review + updateReview: ( + serviceId: string, + reviewId: string, + data: Partial + ): Promise => { + return api.patch( + `/services/${serviceId}/reviews/${reviewId}`, + data + ); + }, + + // Delete review + deleteReview: (serviceId: string, reviewId: string): Promise => { + return api.delete(`/services/${serviceId}/reviews/${reviewId}`); + }, + + // Rate service (quick rating without full review) + rateService: (serviceId: string, rating: number): Promise<{ averageRating: number }> => { + return api.post<{ averageRating: number }>(`/services/${serviceId}/rate`, { + rating, + }); + }, + + // Mark review as helpful + markReviewHelpful: (serviceId: string, reviewId: string): Promise => { + return api.post(`/services/${serviceId}/reviews/${reviewId}/helpful`); + }, + + // Get featured services + getFeaturedServices: (): Promise => { + return api.get('/services/featured'); + }, + + // Get popular services + getPopularServices: (limit?: number): Promise => { + return api.get('/services/popular', { params: { limit } }); + }, + + // Get services by category + getServicesByCategory: ( + category: ServiceCategory, + params?: PaginationParams + ): Promise> => { + return api.get>(`/services/category/${category}`, { + params, + }); + }, + + // Get services by difficulty + getServicesByDifficulty: ( + difficulty: ServiceDifficulty, + params?: PaginationParams + ): Promise> => { + return api.get>(`/services/difficulty/${difficulty}`, { + params, + }); + }, + + // Get service categories with counts + getCategories: (): Promise> => { + return api.get('/services/categories'); + }, + + // Get service tags with counts + getTags: (): Promise> => { + return api.get('/services/tags'); + }, + + // Get service tech stacks with counts + getStacks: (): Promise> => { + return api.get('/services/stacks'); + }, + + // Download service source (if allowed) + downloadSource: (serviceId: string, version?: string): Promise => { + return api.get(`/services/${serviceId}/download`, { + params: { version }, + responseType: 'blob', + }); + }, + + // Get my services (created by current user) + getMyServices: ( + params?: PaginationParams + ): Promise> => { + return api.get>('/services/my', { params }); + }, +}; + +export default servicesApi; diff --git a/src/api/endpoints/teams.api.ts b/src/api/endpoints/teams.api.ts new file mode 100644 index 0000000..c57bcf6 --- /dev/null +++ b/src/api/endpoints/teams.api.ts @@ -0,0 +1,194 @@ +import { api } from '../axios'; +import { + Team, + TeamMember, + TeamInvite, + TeamJoinRequest, + TeamMatchHistory, + TeamStats, + CreateTeamRequest, + UpdateTeamRequest, + InviteToTeamRequest, + UpdateMemberRoleRequest, + TeamSearchParams, + PaginatedResponse, + PaginationParams, +} from '../types'; + +export const teamsApi = { + // Create a new team + createTeam: (data: CreateTeamRequest): Promise => { + return api.post('/teams', data); + }, + + // Get team by ID + getTeam: (teamId: string): Promise => { + return api.get(`/teams/${teamId}`); + }, + + // Get team by tag + getTeamByTag: (tag: string): Promise => { + return api.get(`/teams/tag/${tag}`); + }, + + // Update team + updateTeam: (teamId: string, data: UpdateTeamRequest): Promise => { + return api.patch(`/teams/${teamId}`, data); + }, + + // Delete team + deleteTeam: (teamId: string): Promise => { + return api.delete(`/teams/${teamId}`); + }, + + // Get team members + getTeamMembers: (teamId: string): Promise => { + return api.get(`/teams/${teamId}/members`); + }, + + // Get team stats + getTeamStats: (teamId: string): Promise => { + return api.get(`/teams/${teamId}/stats`); + }, + + // Get team match history + getTeamMatchHistory: ( + teamId: string, + params?: PaginationParams + ): Promise> => { + return api.get>( + `/teams/${teamId}/matches`, + { params } + ); + }, + + // Search teams + searchTeams: ( + params: TeamSearchParams & PaginationParams + ): Promise> => { + return api.get>('/teams/search', { params }); + }, + + // Join team (public team) + joinTeam: (teamId: string, message?: string): Promise => { + return api.post(`/teams/${teamId}/join`, { message }); + }, + + // Leave team + leaveTeam: (teamId: string): Promise => { + return api.post(`/teams/${teamId}/leave`); + }, + + // Invite user to team + inviteToTeam: (teamId: string, data: InviteToTeamRequest): Promise => { + return api.post(`/teams/${teamId}/invites`, data); + }, + + // Get team invites (sent by team) + getTeamInvites: (teamId: string): Promise => { + return api.get(`/teams/${teamId}/invites`); + }, + + // Cancel team invite + cancelInvite: (teamId: string, inviteId: string): Promise => { + return api.delete(`/teams/${teamId}/invites/${inviteId}`); + }, + + // Get pending join requests (for team admins) + getJoinRequests: (teamId: string): Promise => { + return api.get(`/teams/${teamId}/join-requests`); + }, + + // Approve join request + approveJoinRequest: (teamId: string, requestId: string): Promise => { + return api.post(`/teams/${teamId}/join-requests/${requestId}/approve`); + }, + + // Reject join request + rejectJoinRequest: (teamId: string, requestId: string): Promise => { + return api.post(`/teams/${teamId}/join-requests/${requestId}/reject`); + }, + + // Get my invites (received by current user) + getMyInvites: (): Promise => { + return api.get('/teams/invites/me'); + }, + + // Accept invite + acceptInvite: (inviteId: string): Promise => { + return api.post(`/teams/invites/${inviteId}/accept`); + }, + + // Decline invite + declineInvite: (inviteId: string): Promise => { + return api.post(`/teams/invites/${inviteId}/decline`); + }, + + // Get my join requests (sent by current user) + getMyJoinRequests: (): Promise => { + return api.get('/teams/join-requests/me'); + }, + + // Cancel my join request + cancelJoinRequest: (requestId: string): Promise => { + return api.delete(`/teams/join-requests/${requestId}`); + }, + + // Update member role + updateMemberRole: ( + teamId: string, + memberId: string, + data: UpdateMemberRoleRequest + ): Promise => { + return api.patch(`/teams/${teamId}/members/${memberId}`, data); + }, + + // Remove member from team + removeMember: (teamId: string, memberId: string): Promise => { + return api.delete(`/teams/${teamId}/members/${memberId}`); + }, + + // Transfer captaincy + transferCaptain: (teamId: string, newCaptainId: string): Promise => { + return api.post(`/teams/${teamId}/transfer-captain`, { + newCaptainId, + }); + }, + + // Upload team avatar + uploadAvatar: async (teamId: string, file: File): Promise<{ avatarUrl: string }> => { + const formData = new FormData(); + formData.append('avatar', file); + return api.post<{ avatarUrl: string }>(`/teams/${teamId}/avatar`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }, + + // Upload team banner + uploadBanner: async (teamId: string, file: File): Promise<{ bannerUrl: string }> => { + const formData = new FormData(); + formData.append('banner', file); + return api.post<{ bannerUrl: string }>(`/teams/${teamId}/banner`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }, + + // Get featured teams + getFeaturedTeams: (): Promise => { + return api.get('/teams/featured'); + }, + + // Get recruiting teams + getRecruitingTeams: ( + params?: PaginationParams + ): Promise> => { + return api.get>('/teams/recruiting', { params }); + }, + + // Check if team tag is available + checkTag: (tag: string): Promise<{ available: boolean }> => { + return api.get<{ available: boolean }>(`/teams/check-tag/${tag}`); + }, +}; + +export default teamsApi; diff --git a/src/api/endpoints/training.api.ts b/src/api/endpoints/training.api.ts new file mode 100644 index 0000000..011a1e6 --- /dev/null +++ b/src/api/endpoints/training.api.ts @@ -0,0 +1,244 @@ +import { api } from '../axios'; +import { + Track, + Exercise, + ExerciseResult, + Progress, + SkillMapData, + TrackProgress, + StartExerciseRequest, + StartExerciseResponse, + SubmitExerciseRequest, + SubmitExerciseResponse, + UseHintRequest, + UseHintResponse, + TrackSearchParams, + PaginatedResponse, + PaginationParams, +} from '../types'; + +export const trainingApi = { + // Get all tracks + getTracks: ( + params?: TrackSearchParams & PaginationParams + ): Promise> => { + return api.get>('/training/tracks', { params }); + }, + + // Get track by ID + getTrack: (trackId: string): Promise => { + return api.get(`/training/tracks/${trackId}`); + }, + + // Get track by slug + getTrackBySlug: (slug: string): Promise => { + return api.get(`/training/tracks/slug/${slug}`); + }, + + // Enroll in track + enrollInTrack: (trackId: string): Promise => { + return api.post(`/training/tracks/${trackId}/enroll`); + }, + + // Unenroll from track + unenrollFromTrack: (trackId: string): Promise => { + return api.delete(`/training/tracks/${trackId}/enroll`); + }, + + // Get track progress + getTrackProgress: (trackId: string): Promise => { + return api.get(`/training/tracks/${trackId}/progress`); + }, + + // Get exercise by ID + getExercise: (exerciseId: string): Promise => { + return api.get(`/training/exercises/${exerciseId}`); + }, + + // Get exercise by slug + getExerciseBySlug: (trackSlug: string, exerciseSlug: string): Promise => { + return api.get( + `/training/tracks/slug/${trackSlug}/exercises/slug/${exerciseSlug}` + ); + }, + + // Start exercise + startExercise: (data: StartExerciseRequest): Promise => { + return api.post('/training/exercises/start', data); + }, + + // Submit exercise + submitExercise: (data: SubmitExerciseRequest): Promise => { + return api.post('/training/exercises/submit', data); + }, + + // Get exercise result + getExerciseResult: (exerciseId: string): Promise => { + return api.get( + `/training/exercises/${exerciseId}/result` + ); + }, + + // Get available hints + getAvailableHints: (exerciseId: string): Promise<{ + hints: Array<{ + id: string; + order: number; + title: string; + isUnlocked: boolean; + costPercentage: number; + }>; + usedHints: number; + totalHints: number; + }> => { + return api.get(`/training/exercises/${exerciseId}/hints`); + }, + + // Use hint + useHint: (data: UseHintRequest): Promise => { + return api.post('/training/exercises/use-hint', data); + }, + + // Get overall progress + getProgress: (): Promise => { + return api.get('/training/progress'); + }, + + // Get skill map + getSkillMap: (): Promise => { + return api.get('/training/skill-map'); + }, + + // Get recommended tracks + getRecommendedTracks: (limit?: number): Promise => { + return api.get('/training/recommendations/tracks', { + params: { limit }, + }); + }, + + // Get recommended exercises + getRecommendedExercises: (limit?: number): Promise => { + return api.get('/training/recommendations/exercises', { + params: { limit }, + }); + }, + + // Get featured tracks + getFeaturedTracks: (): Promise => { + return api.get('/training/tracks/featured'); + }, + + // Get popular tracks + getPopularTracks: (limit?: number): Promise => { + return api.get('/training/tracks/popular', { + params: { limit }, + }); + }, + + // Get enrolled tracks + getEnrolledTracks: (): Promise => { + return api.get('/training/my-tracks'); + }, + + // Get completed tracks + getCompletedTracks: (): Promise => { + return api.get('/training/completed-tracks'); + }, + + // Get exercise history (all attempts) + getExerciseHistory: ( + exerciseId: string + ): Promise> => { + return api.get(`/training/exercises/${exerciseId}/history`); + }, + + // Reset exercise progress + resetExerciseProgress: (exerciseId: string): Promise => { + return api.post(`/training/exercises/${exerciseId}/reset`); + }, + + // Get daily challenge + getDailyChallenge: (): Promise => { + return api.get('/training/daily-challenge'); + }, + + // Get streak info + getStreakInfo: (): Promise<{ + currentStreak: number; + longestStreak: number; + lastActivityDate: string; + streakFreezeAvailable: boolean; + }> => { + return api.get('/training/streak'); + }, + + // Use streak freeze + useStreakFreeze: (): Promise<{ success: boolean; freezesRemaining: number }> => { + return api.post('/training/streak/freeze'); + }, + + // Get certificates + getCertificates: (): Promise> => { + return api.get('/training/certificates'); + }, + + // Generate certificate for completed track + generateCertificate: (trackId: string): Promise<{ + certificateUrl: string; + verificationCode: string; + }> => { + return api.post(`/training/tracks/${trackId}/certificate`); + }, + + // Get leaderboard for track + getTrackLeaderboard: ( + trackId: string, + params?: PaginationParams + ): Promise> => { + return api.get(`/training/tracks/${trackId}/leaderboard`, { params }); + }, + + // Rate track + rateTrack: ( + trackId: string, + rating: number, + feedback?: string + ): Promise<{ averageRating: number }> => { + return api.post(`/training/tracks/${trackId}/rate`, { rating, feedback }); + }, + + // Report issue with exercise + reportExerciseIssue: ( + exerciseId: string, + type: 'bug' | 'unclear' | 'incorrect' | 'other', + description: string + ): Promise => { + return api.post(`/training/exercises/${exerciseId}/report`, { + type, + description, + }); + }, +}; + +export default trainingApi; diff --git a/src/api/endpoints/users.api.ts b/src/api/endpoints/users.api.ts new file mode 100644 index 0000000..da5e03f --- /dev/null +++ b/src/api/endpoints/users.api.ts @@ -0,0 +1,173 @@ +import { api } from '../axios'; +import { + User, + UserStats, + Achievement, + PaginatedResponse, + PaginationParams, +} from '../types'; + +export interface UserProfile extends User { + bio: string; + socialLinks: { + github?: string; + twitter?: string; + linkedin?: string; + website?: string; + ctftime?: string; + }; + isFollowing?: boolean; + followersCount: number; + followingCount: number; +} + +export interface UserActivity { + id: string; + type: 'match_played' | 'achievement_unlocked' | 'exercise_completed' | 'team_joined' | 'rank_change'; + title: string; + description: string; + data: Record; + timestamp: string; +} + +export interface LeaderboardUser { + position: number; + userId: string; + username: string; + displayName: string; + avatar: string | null; + rating: number; + ratingChange: number; + matchesPlayed: number; + winRate: number; + teamId: string | null; + teamName: string | null; +} + +export const usersApi = { + // Get user by ID + getUser: (userId: string): Promise => { + return api.get(`/users/${userId}`); + }, + + // Get user by username + getUserByUsername: (username: string): Promise => { + return api.get(`/users/username/${username}`); + }, + + // Get user stats + getUserStats: (userId: string): Promise => { + return api.get(`/users/${userId}/stats`); + }, + + // Get user achievements + getUserAchievements: (userId: string): Promise => { + return api.get(`/users/${userId}/achievements`); + }, + + // Get user activity feed + getUserActivity: ( + userId: string, + params?: PaginationParams + ): Promise> => { + return api.get>(`/users/${userId}/activity`, { + params, + }); + }, + + // Search users + searchUsers: ( + query: string, + params?: PaginationParams + ): Promise> => { + return api.get>('/users/search', { + params: { query, ...params }, + }); + }, + + // Get global leaderboard + getLeaderboard: (params?: { + period?: 'week' | 'month' | 'season' | 'all_time'; + page?: number; + pageSize?: number; + }): Promise> => { + return api.get>('/users/leaderboard', { + params, + }); + }, + + // Follow user + followUser: (userId: string): Promise => { + return api.post(`/users/${userId}/follow`); + }, + + // Unfollow user + unfollowUser: (userId: string): Promise => { + return api.delete(`/users/${userId}/follow`); + }, + + // Get followers + getFollowers: ( + userId: string, + params?: PaginationParams + ): Promise> => { + return api.get>(`/users/${userId}/followers`, { + params, + }); + }, + + // Get following + getFollowing: ( + userId: string, + params?: PaginationParams + ): Promise> => { + return api.get>(`/users/${userId}/following`, { + params, + }); + }, + + // Get user match history + getUserMatches: ( + userId: string, + params?: PaginationParams & { status?: string; mode?: string } + ): Promise> => { + return api.get(`/users/${userId}/matches`, { params }); + }, + + // Get online users count + getOnlineCount: (): Promise<{ count: number }> => { + return api.get<{ count: number }>('/users/online-count'); + }, + + // Report user + reportUser: (userId: string, reason: string, details?: string): Promise => { + return api.post(`/users/${userId}/report`, { reason, details }); + }, + + // Block user + blockUser: (userId: string): Promise => { + return api.post(`/users/${userId}/block`); + }, + + // Unblock user + unblockUser: (userId: string): Promise => { + return api.delete(`/users/${userId}/block`); + }, + + // Get blocked users + getBlockedUsers: (): Promise => { + return api.get('/users/blocked'); + }, +}; + +export default usersApi; diff --git a/src/api/types/admin.types.ts b/src/api/types/admin.types.ts new file mode 100644 index 0000000..3ec9328 --- /dev/null +++ b/src/api/types/admin.types.ts @@ -0,0 +1,312 @@ +// Admin types + +import { UserRole, UserStatus } from './auth.types'; +import { MatchStatus } from './match.types'; +import { ServiceStatus } from './service.types'; + +export interface AdminDashboard { + systemStats: SystemStats; + activeMatches: ActiveMatchInfo[]; + recentActivity: AdminActivityLog[]; + alerts: SystemAlert[]; + queueStatus: QueueStatus; +} + +export interface SystemStats { + totalUsers: number; + activeUsers: number; + newUsersToday: number; + newUsersWeek: number; + totalTeams: number; + activeTeams: number; + totalMatches: number; + activeMatches: number; + matchesToday: number; + totalServices: number; + pendingServices: number; + totalExercises: number; + systemUptime: number; + serverLoad: ServerLoad; + storageUsage: StorageUsage; +} + +export interface ServerLoad { + cpu: number; + memory: number; + disk: number; + network: number; +} + +export interface StorageUsage { + total: number; + used: number; + available: number; + breakdown: StorageBreakdown; +} + +export interface StorageBreakdown { + services: number; + replays: number; + logs: number; + backups: number; + other: number; +} + +export interface ActiveMatchInfo { + matchId: string; + matchTitle: string; + mode: string; + status: MatchStatus; + teamsCount: number; + currentRound: number; + totalRounds: number; + startedAt: string; + healthStatus: HealthStatus; +} + +export type HealthStatus = 'healthy' | 'degraded' | 'critical' | 'unknown'; + +export interface AdminActivityLog { + id: string; + adminId: string; + adminName: string; + action: AdminAction; + targetType: AdminTargetType; + targetId: string; + targetName: string; + details: Record; + timestamp: string; + ip: string; +} + +export type AdminAction = + | 'create' + | 'update' + | 'delete' + | 'ban' + | 'unban' + | 'approve' + | 'reject' + | 'start' + | 'stop' + | 'pause' + | 'resume' + | 'config_change' + | 'role_change' + | 'system_restart'; + +export type AdminTargetType = + | 'user' + | 'team' + | 'match' + | 'service' + | 'exercise' + | 'system' + | 'config'; + +export interface SystemAlert { + id: string; + severity: AlertSeverity; + type: AlertType; + title: string; + message: string; + source: string; + isResolved: boolean; + createdAt: string; + resolvedAt: string | null; + resolvedBy: string | null; +} + +export type AlertSeverity = 'info' | 'warning' | 'error' | 'critical'; + +export type AlertType = + | 'high_load' + | 'service_down' + | 'security_threat' + | 'disk_space' + | 'memory_usage' + | 'match_error' + | 'checker_timeout' + | 'database_issue' + | 'network_issue'; + +export interface QueueStatus { + checkerQueue: QueueInfo; + scorerQueue: QueueInfo; + validationQueue: QueueInfo; + notificationQueue: QueueInfo; +} + +export interface QueueInfo { + pending: number; + processing: number; + completed: number; + failed: number; + avgProcessingTime: number; + isHealthy: boolean; +} + +export interface AdminUserListParams { + search?: string; + role?: UserRole; + status?: UserStatus; + teamId?: string; + createdFrom?: string; + createdTo?: string; + lastLoginFrom?: string; + lastLoginTo?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + page?: number; + pageSize?: number; +} + +export interface AdminUpdateUserRequest { + role?: UserRole; + status?: UserStatus; + displayName?: string; + email?: string; + emailVerified?: boolean; + banReason?: string; + banExpires?: string; +} + +export interface AdminCreateMatchRequest { + title: string; + description?: string; + mode: string; + config: Record; + scheduledAt?: string; + teamIds?: string[]; + serviceIds: string[]; + isRanked?: boolean; + seasonId?: string; +} + +export interface AdminMatchControl { + matchId: string; + action: 'start' | 'stop' | 'pause' | 'resume' | 'cancel' | 'restart'; + reason?: string; +} + +export interface AdminServiceListParams { + search?: string; + status?: ServiceStatus; + category?: string; + authorId?: string; + isFeatured?: boolean; + createdFrom?: string; + createdTo?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + page?: number; + pageSize?: number; +} + +export interface AdminUpdateServiceRequest { + status?: ServiceStatus; + isFeatured?: boolean; + isPublic?: boolean; + rejectionReason?: string; +} + +export interface AdminServiceReview { + serviceId: string; + decision: 'approve' | 'reject' | 'needs_revision'; + feedback: string; + suggestedChanges?: string[]; +} + +export interface SystemConfig { + general: GeneralConfig; + matches: MatchesConfig; + security: SecurityConfig; + email: EmailConfig; + storage: StorageConfig; + integrations: IntegrationsConfig; +} + +export interface GeneralConfig { + siteName: string; + siteUrl: string; + maintenanceMode: boolean; + maintenanceMessage: string; + registrationEnabled: boolean; + inviteOnly: boolean; + defaultUserRole: UserRole; + maxTeamSize: number; +} + +export interface MatchesConfig { + maxConcurrentMatches: number; + defaultRoundDuration: number; + defaultTotalRounds: number; + checkerTimeout: number; + checkerInterval: number; + flagLifetime: number; + scoringPolicy: string; + replayRetention: number; +} + +export interface SecurityConfig { + sessionTimeout: number; + maxLoginAttempts: number; + lockoutDuration: number; + passwordMinLength: number; + requireEmailVerification: boolean; + twoFactorEnabled: boolean; + apiRateLimit: number; + flagSubmitRateLimit: number; +} + +export interface EmailConfig { + provider: string; + fromAddress: string; + fromName: string; + smtpHost?: string; + smtpPort?: number; + smtpSecure?: boolean; +} + +export interface StorageConfig { + provider: string; + bucket: string; + region: string; + maxFileSize: number; + allowedFileTypes: string[]; +} + +export interface IntegrationsConfig { + discordWebhook?: string; + slackWebhook?: string; + ctftimeTeamId?: string; + googleAnalyticsId?: string; + sentryDsn?: string; +} + +export interface UpdateSystemConfigRequest { + section: keyof SystemConfig; + config: Partial; +} + +export interface SystemHealthCheck { + overall: HealthStatus; + services: ServiceHealthCheck[]; + lastCheck: string; + nextCheck: string; +} + +export interface ServiceHealthCheck { + name: string; + status: HealthStatus; + latency: number; + message: string | null; + lastCheck: string; +} + +export interface MaintenanceRequest { + enabled: boolean; + message: string; + estimatedDuration?: number; + allowAdminAccess?: boolean; +} diff --git a/src/api/types/analytics.types.ts b/src/api/types/analytics.types.ts new file mode 100644 index 0000000..e55d944 --- /dev/null +++ b/src/api/types/analytics.types.ts @@ -0,0 +1,483 @@ +// Analytics types + +import { ServiceCategory, ServiceDifficulty } from './service.types'; +import { TrackRole, SkillCategory } from './training.types'; + +export interface TeamAnalytics { + teamId: string; + teamName: string; + period: AnalyticsPeriod; + overview: TeamOverview; + performance: TeamPerformance; + strengths: StrengthWeakness[]; + weaknesses: StrengthWeakness[]; + trends: TeamTrend[]; + comparison: TeamComparison | null; + recommendations: AIRecommendation[]; + memberAnalytics: MemberAnalytics[]; + serviceAnalytics: TeamServiceAnalytics[]; + generatedAt: string; +} + +export interface AnalyticsPeriod { + from: string; + to: string; + type: 'week' | 'month' | 'season' | 'all_time' | 'custom'; +} + +export interface TeamOverview { + matchesPlayed: number; + matchesWon: number; + winRate: number; + avgPosition: number; + bestPosition: number; + worstPosition: number; + totalScore: number; + avgScore: number; + totalFlagsCaptured: number; + totalFlagsLost: number; + avgSLA: number; + rating: number; + ratingChange: number; + rank: number; + rankChange: number; +} + +export interface TeamPerformance { + attackEfficiency: number; + defenseEfficiency: number; + slaConsistency: number; + responseTime: number; + exploitSpeed: number; + patchSpeed: number; + coordination: number; + adaptability: number; +} + +export interface StrengthWeakness { + type: 'attack' | 'defense' | 'sla' | 'service' | 'skill'; + name: string; + description: string; + score: number; + trend: 'improving' | 'stable' | 'declining'; + relatedServices?: string[]; + relatedSkills?: string[]; +} + +export interface TeamTrend { + metric: string; + values: TrendPoint[]; + trend: 'up' | 'down' | 'stable'; + changePercent: number; +} + +export interface TrendPoint { + timestamp: string; + value: number; + label: string; +} + +export interface TeamComparison { + compareToTeamId: string; + compareToTeamName: string; + metrics: ComparisonMetric[]; +} + +export interface ComparisonMetric { + name: string; + ourValue: number; + theirValue: number; + difference: number; + differencePercent: number; + winner: 'us' | 'them' | 'tie'; +} + +export interface MemberAnalytics { + userId: string; + userName: string; + role: TrackRole; + contribution: number; + attackContribution: number; + defenseContribution: number; + activityLevel: number; + skillGrowth: number; + strengths: string[]; + areasToImprove: string[]; +} + +export interface TeamServiceAnalytics { + serviceId: string; + serviceName: string; + category: ServiceCategory; + attackSuccess: number; + defenseSuccess: number; + avgSLA: number; + flagsCaptured: number; + flagsLost: number; + exploitTime: number; + patchTime: number; + recommendation: string; +} + +export interface PlayerAnalytics { + userId: string; + userName: string; + period: AnalyticsPeriod; + overview: PlayerOverview; + skillAnalysis: SkillAnalysis; + roleAnalysis: RoleAnalysis; + learningPath: LearningPath; + achievements: AchievementAnalysis; + recommendations: AIRecommendation[]; + activityHeatmap: ActivityHeatmap; + generatedAt: string; +} + +export interface PlayerOverview { + matchesPlayed: number; + teamsPlayed: number; + totalScore: number; + avgScore: number; + flagsCaptured: number; + flagsDefended: number; + trainingProgress: number; + exercisesCompleted: number; + hoursSpent: number; + currentStreak: number; + longestStreak: number; + rating: number; + percentile: number; +} + +export interface SkillAnalysis { + overallLevel: number; + skillLevels: SkillLevelAnalysis[]; + skillGrowth: SkillGrowthData[]; + strongestSkills: string[]; + weakestSkills: string[]; + recentlyImproved: string[]; +} + +export interface SkillLevelAnalysis { + skillId: string; + skillName: string; + category: SkillCategory; + level: number; + maxLevel: number; + percentile: number; + trend: 'up' | 'down' | 'stable'; +} + +export interface SkillGrowthData { + skillId: string; + skillName: string; + history: TrendPoint[]; +} + +export interface RoleAnalysis { + primaryRole: TrackRole; + secondaryRole: TrackRole | null; + roleScores: RoleScore[]; + roleRecommendation: string; + roleComparison: RoleComparison[]; +} + +export interface RoleScore { + role: TrackRole; + score: number; + percentile: number; + matchingSkills: string[]; +} + +export interface RoleComparison { + role: TrackRole; + avgScore: number; + yourScore: number; + gap: number; +} + +export interface LearningPath { + currentTrack: string | null; + completedTracks: string[]; + suggestedTracks: SuggestedTrack[]; + nextMilestone: Milestone; + longTermGoals: Goal[]; +} + +export interface SuggestedTrack { + trackId: string; + trackName: string; + reason: string; + matchScore: number; + estimatedTime: number; + priority: number; +} + +export interface Milestone { + type: string; + title: string; + description: string; + progress: number; + target: number; + estimatedCompletion: string; +} + +export interface Goal { + id: string; + title: string; + description: string; + progress: number; + target: number; + deadline: string | null; +} + +export interface AchievementAnalysis { + totalUnlocked: number; + totalAvailable: number; + recentUnlocks: string[]; + nearUnlocks: NearAchievement[]; + rareAchievements: string[]; +} + +export interface NearAchievement { + achievementId: string; + name: string; + progress: number; + target: number; + hint: string; +} + +export interface ActivityHeatmap { + data: HeatmapCell[]; + mostActiveDay: string; + mostActiveHour: number; + totalActiveDays: number; +} + +export interface HeatmapCell { + date: string; + hour: number; + value: number; + activities: string[]; +} + +export interface MatchAnalytics { + matchId: string; + matchTitle: string; + overview: MatchAnalyticsOverview; + timeline: MatchTimeline; + teamAnalytics: MatchTeamAnalytics[]; + serviceAnalytics: MatchServiceAnalytics[]; + attackPatterns: AttackPattern[]; + keyMoments: KeyMomentAnalysis[]; + aiInsights: AIRecommendation[]; + heatmaps: MatchHeatmaps; + generatedAt: string; +} + +export interface MatchAnalyticsOverview { + duration: number; + totalRounds: number; + totalFlags: number; + totalAttacks: number; + successfulAttacks: number; + avgSLA: number; + competitiveness: number; + volatility: number; + dominantTeam: string | null; +} + +export interface MatchTimeline { + events: TimelineEventAnalysis[]; + phases: MatchPhase[]; + turningPoints: TurningPoint[]; +} + +export interface TimelineEventAnalysis { + timestamp: string; + round: number; + type: string; + significance: number; + teams: string[]; + description: string; +} + +export interface MatchPhase { + name: string; + fromRound: number; + toRound: number; + characteristics: string[]; + dominantTeam: string | null; +} + +export interface TurningPoint { + round: number; + timestamp: string; + description: string; + impact: number; + beforeLeader: string; + afterLeader: string; +} + +export interface MatchTeamAnalytics { + teamId: string; + teamName: string; + finalPosition: number; + scoreBreakdown: ScoreBreakdownAnalysis; + performance: PerformanceMetrics; + strategy: StrategyAnalysis; +} + +export interface ScoreBreakdownAnalysis { + attack: number; + defense: number; + sla: number; + penalties: number; + bonuses: number; + byRound: RoundScore[]; +} + +export interface RoundScore { + round: number; + total: number; + attack: number; + defense: number; + sla: number; +} + +export interface PerformanceMetrics { + attackEfficiency: number; + defenseEfficiency: number; + exploitSpeed: number; + patchSpeed: number; + slaUptime: number; + consistency: number; +} + +export interface StrategyAnalysis { + type: 'aggressive' | 'defensive' | 'balanced' | 'adaptive'; + focusedServices: string[]; + attackTargets: string[]; + timeline: StrategyPhase[]; +} + +export interface StrategyPhase { + fromRound: number; + toRound: number; + strategy: string; + effectiveness: number; +} + +export interface MatchServiceAnalytics { + serviceId: string; + serviceName: string; + category: ServiceCategory; + difficulty: ServiceDifficulty; + totalExploits: number; + uniqueExploiters: number; + firstBlood: FirstBloodAnalysis | null; + avgExploitTime: number; + patchRate: number; + avgSLA: number; + vulnerabilitiesExploited: string[]; +} + +export interface FirstBloodAnalysis { + teamId: string; + teamName: string; + round: number; + timestamp: string; + timeFromStart: number; +} + +export interface AttackPattern { + id: string; + type: string; + frequency: number; + successRate: number; + targetServices: string[]; + attackerTeams: string[]; + peakRounds: number[]; + description: string; +} + +export interface KeyMomentAnalysis { + round: number; + timestamp: string; + type: string; + title: string; + description: string; + impact: number; + involvedTeams: string[]; + scoreChanges: Record; +} + +export interface MatchHeatmaps { + attackHeatmap: HeatmapData; + scoreHeatmap: HeatmapData; + slaHeatmap: HeatmapData; +} + +export interface HeatmapData { + xLabels: string[]; + yLabels: string[]; + values: number[][]; + min: number; + max: number; +} + +export interface AIRecommendation { + id: string; + type: RecommendationType; + priority: 'low' | 'medium' | 'high' | 'critical'; + title: string; + description: string; + rationale: string; + actionItems: ActionItem[]; + relatedSkills: string[]; + relatedServices: string[]; + estimatedImpact: number; + estimatedEffort: number; + confidence: number; + generatedAt: string; +} + +export type RecommendationType = + | 'skill_improvement' + | 'strategy_change' + | 'service_focus' + | 'team_coordination' + | 'training_suggestion' + | 'role_adjustment' + | 'tool_recommendation' + | 'resource_allocation'; + +export interface ActionItem { + id: string; + action: string; + priority: number; + estimatedTime: number; + resources: string[]; +} + +export interface TeamAnalyticsParams { + teamId: string; + period?: AnalyticsPeriod; + compareToTeamId?: string; + includeMembers?: boolean; + includeRecommendations?: boolean; +} + +export interface PlayerAnalyticsParams { + userId: string; + period?: AnalyticsPeriod; + includeSkillAnalysis?: boolean; + includeRecommendations?: boolean; +} + +export interface MatchAnalyticsParams { + matchId: string; + includeHeatmaps?: boolean; + includeAIInsights?: boolean; + teamId?: string; +} diff --git a/src/api/types/auth.types.ts b/src/api/types/auth.types.ts new file mode 100644 index 0000000..7c32056 --- /dev/null +++ b/src/api/types/auth.types.ts @@ -0,0 +1,135 @@ +// Authentication types + +export interface User { + id: string; + username: string; + email: string; + displayName: string; + avatar: string | null; + role: UserRole; + teamId: string | null; + status: UserStatus; + stats: UserStats; + achievements: Achievement[]; + createdAt: string; + lastLoginAt: string | null; + emailVerified: boolean; + twoFactorEnabled: boolean; +} + +export type UserRole = 'user' | 'organizer' | 'admin' | 'superadmin'; + +export type UserStatus = 'active' | 'inactive' | 'banned' | 'pending'; + +export interface UserStats { + matchesPlayed: number; + matchesWon: number; + flagsCaptured: number; + flagsDefended: number; + totalScore: number; + rank: number; + rating: number; + trainingProgress: number; + hoursPlayed: number; +} + +export interface Achievement { + id: string; + name: string; + description: string; + icon: string; + rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + unlockedAt: string; + progress?: number; + maxProgress?: number; +} + +export interface LoginRequest { + email: string; + password: string; + rememberMe?: boolean; + twoFactorCode?: string; +} + +export interface RegisterRequest { + username: string; + email: string; + password: string; + confirmPassword: string; + displayName?: string; + acceptTerms: boolean; +} + +export interface AuthResponse { + user: User; + tokens: TokenPair; +} + +export interface TokenPair { + accessToken: string; + refreshToken: string; + expiresIn: number; + refreshExpiresIn: number; +} + +export interface ForgotPasswordRequest { + email: string; +} + +export interface ResetPasswordRequest { + token: string; + password: string; + confirmPassword: string; +} + +export interface ChangePasswordRequest { + currentPassword: string; + newPassword: string; + confirmPassword: string; +} + +export interface UpdateProfileRequest { + displayName?: string; + avatar?: string; + bio?: string; + socialLinks?: SocialLinks; + notificationSettings?: NotificationSettings; +} + +export interface SocialLinks { + github?: string; + twitter?: string; + linkedin?: string; + website?: string; + ctftime?: string; +} + +export interface NotificationSettings { + emailNotifications: boolean; + matchReminders: boolean; + teamInvites: boolean; + achievementUnlocks: boolean; + systemAnnouncements: boolean; + weeklyDigest: boolean; +} + +export interface ApiKey { + id: string; + name: string; + prefix: string; + createdAt: string; + lastUsedAt: string | null; + expiresAt: string | null; + permissions: string[]; +} + +export interface CreateApiKeyRequest { + name: string; + permissions: string[]; + expiresIn?: number; +} + +export interface CreateApiKeyResponse { + apiKey: ApiKey; + secret: string; // Only shown once +} diff --git a/src/api/types/common.types.ts b/src/api/types/common.types.ts new file mode 100644 index 0000000..d35ef17 --- /dev/null +++ b/src/api/types/common.types.ts @@ -0,0 +1,71 @@ +// Common types used across the application + +export interface PaginatedResponse { + data: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; +} + +export interface ApiError { + code: string; + message: string; + details?: Record; + timestamp: string; + path: string; +} + +export type SortOrder = 'asc' | 'desc'; + +export interface SortParams { + field: string; + order: SortOrder; +} + +export interface FilterParams { + [key: string]: string | number | boolean | string[] | undefined; +} + +export interface PaginationParams { + page?: number; + pageSize?: number; + sort?: SortParams; + filters?: FilterParams; +} + +export interface ApiResponse { + success: boolean; + data: T; + message?: string; +} + +export interface SelectOption { + value: string; + label: string; +} + +export interface DateRange { + from: string; + to: string; +} + +export interface Coordinates { + x: number; + y: number; +} + +export interface TimeRange { + start: number; + end: number; +} + +export type LoadingState = 'idle' | 'loading' | 'success' | 'error'; + +export interface AsyncState { + data: T | null; + loading: boolean; + error: string | null; +} diff --git a/src/api/types/flag.types.ts b/src/api/types/flag.types.ts new file mode 100644 index 0000000..772feef --- /dev/null +++ b/src/api/types/flag.types.ts @@ -0,0 +1,127 @@ +// Flag types + +export interface Flag { + id: string; + matchId: string; + roundNumber: number; + serviceId: string; + serviceName: string; + teamId: string; + teamName: string; + value: string; + status: FlagStatus; + generatedAt: string; + expiresAt: string; + capturedAt: string | null; + capturedBy: string | null; + capturedByTeam: string | null; + points: number; +} + +export type FlagStatus = + | 'active' + | 'captured' + | 'expired' + | 'stolen' + | 'invalid'; + +export interface FlagSubmission { + id: string; + matchId: string; + teamId: string; + teamName: string; + flagValue: string; + result: FlagSubmissionResult; + message: string; + points: number; + targetTeamId: string | null; + targetTeamName: string | null; + serviceId: string | null; + serviceName: string | null; + roundNumber: number; + submittedAt: string; + latency: number; // ms +} + +export type FlagSubmissionResult = + | 'accepted' + | 'duplicate' + | 'expired' + | 'own_flag' + | 'invalid' + | 'not_found' + | 'rate_limited' + | 'match_not_active'; + +export interface SubmitFlagRequest { + matchId: string; + flag: string; +} + +export interface SubmitFlagResponse { + success: boolean; + result: FlagSubmissionResult; + message: string; + points: number; + targetTeam: string | null; + service: string | null; +} + +export interface FlagHistoryParams { + matchId: string; + teamId?: string; + serviceId?: string; + status?: FlagStatus; + round?: number; + limit?: number; + offset?: number; +} + +export interface FlagStats { + matchId: string; + teamId: string; + totalCaptured: number; + totalLost: number; + totalPoints: number; + byService: FlagStatsByService[]; + byRound: FlagStatsByRound[]; + captureRate: number; + avgCaptureTime: number; +} + +export interface FlagStatsByService { + serviceId: string; + serviceName: string; + captured: number; + lost: number; + points: number; +} + +export interface FlagStatsByRound { + round: number; + captured: number; + lost: number; + points: number; +} + +export interface FlagSubmissionStats { + total: number; + accepted: number; + duplicate: number; + expired: number; + ownFlag: number; + invalid: number; + successRate: number; +} + +export interface BulkSubmitFlagsRequest { + matchId: string; + flags: string[]; +} + +export interface BulkSubmitFlagsResponse { + results: SubmitFlagResponse[]; + accepted: number; + rejected: number; + totalPoints: number; +} diff --git a/src/api/types/index.ts b/src/api/types/index.ts new file mode 100644 index 0000000..c6f93c8 --- /dev/null +++ b/src/api/types/index.ts @@ -0,0 +1,38 @@ +// Export all types from a single entry point + +export * from './common.types'; +export * from './auth.types'; +export * from './team.types'; +export * from './match.types'; +export * from './round.types'; +export * from './service.types'; +export * from './scoreboard.types'; +export * from './flag.types'; +export { + type LogEntry, + type LogLevel, + type EventType, + type LogSource, + type LogMetadata, + type TimelineEvent as LogTimelineEvent, + type TimelineTeamInfo, + type TimelineServiceInfo, + type EventImportance, + type ReplayFrame, + type ReplayScoreboardState, + type ReplayTeamScore, + type ReplayServiceStatus, + type ReplayData, + type ReplayTeamInfo, + type ReplayServiceInfo, + type KeyMoment, + type LogFilterParams, + type LogStreamConfig, + type EventTimelineParams, + type ReplayParams +} from './log.types'; +export * from './training.types'; +export * from './analytics.types'; +export * from './notification.types'; +export * from './admin.types'; +export * from './season.types'; diff --git a/src/api/types/log.types.ts b/src/api/types/log.types.ts new file mode 100644 index 0000000..01d1c5a --- /dev/null +++ b/src/api/types/log.types.ts @@ -0,0 +1,208 @@ +// Log types + +export interface LogEntry { + id: string; + matchId: string; + roundNumber: number; + timestamp: string; + level: LogLevel; + type: EventType; + source: LogSource; + teamId: string | null; + teamName: string | null; + targetTeamId: string | null; + targetTeamName: string | null; + serviceId: string | null; + serviceName: string | null; + message: string; + details: Record; + metadata: LogMetadata; +} + +export type LogLevel = 'debug' | 'info' | 'warning' | 'error' | 'critical'; + +export type EventType = + | 'match_start' + | 'match_end' + | 'match_pause' + | 'match_resume' + | 'round_start' + | 'round_end' + | 'flag_generated' + | 'flag_captured' + | 'flag_expired' + | 'flag_submitted' + | 'service_check' + | 'service_up' + | 'service_down' + | 'service_corrupt' + | 'attack_detected' + | 'exploit_attempt' + | 'score_update' + | 'penalty' + | 'bonus' + | 'team_join' + | 'team_leave' + | 'system_info' + | 'system_error' + | 'admin_action' + | 'network_event'; + +export type LogSource = + | 'system' + | 'checker' + | 'scorer' + | 'network' + | 'team' + | 'admin' + | 'api'; + +export interface LogMetadata { + ip?: string; + userAgent?: string; + requestId?: string; + traceId?: string; + spanId?: string; + duration?: number; + statusCode?: number; +} + +export interface TimelineEvent { + id: string; + type: EventType; + timestamp: string; + relativeTime: number; + round: number; + title: string; + description: string; + icon: string; + color: string; + teams: TimelineTeamInfo[]; + service: TimelineServiceInfo | null; + score: number | null; + importance: EventImportance; + linkedEvents: string[]; +} + +export interface TimelineTeamInfo { + teamId: string; + teamName: string; + teamTag: string; + role: 'attacker' | 'defender' | 'neutral'; +} + +export interface TimelineServiceInfo { + serviceId: string; + serviceName: string; +} + +export type EventImportance = 'low' | 'medium' | 'high' | 'critical'; + +export interface ReplayFrame { + timestamp: string; + relativeTime: number; + round: number; + scoreboard: ReplayScoreboardState; + events: TimelineEvent[]; + serviceStatuses: ReplayServiceStatus[]; +} + +export interface ReplayScoreboardState { + entries: ReplayTeamScore[]; + timestamp: string; +} + +export interface ReplayTeamScore { + teamId: string; + teamName: string; + position: number; + score: number; + attackScore: number; + defenseScore: number; + slaScore: number; +} + +export interface ReplayServiceStatus { + teamId: string; + serviceId: string; + status: string; + lastCheck: string; +} + +export interface ReplayData { + matchId: string; + matchTitle: string; + duration: number; + totalRounds: number; + teams: ReplayTeamInfo[]; + services: ReplayServiceInfo[]; + frames: ReplayFrame[]; + keyMoments: KeyMoment[]; +} + +export interface ReplayTeamInfo { + id: string; + name: string; + tag: string; + avatar: string | null; + color: string; +} + +export interface ReplayServiceInfo { + id: string; + name: string; + category: string; +} + +export interface KeyMoment { + timestamp: string; + relativeTime: number; + round: number; + type: string; + title: string; + description: string; +} + +export interface LogFilterParams { + matchId: string; + levels?: LogLevel[]; + types?: EventType[]; + sources?: LogSource[]; + teamId?: string; + serviceId?: string; + fromTimestamp?: string; + toTimestamp?: string; + fromRound?: number; + toRound?: number; + search?: string; + limit?: number; + offset?: number; +} + +export interface LogStreamConfig { + matchId: string; + filters?: { + levels?: LogLevel[]; + types?: EventType[]; + teamId?: string; + serviceId?: string; + }; + bufferSize?: number; +} + +export interface EventTimelineParams { + matchId: string; + fromRound?: number; + toRound?: number; + teamIds?: string[]; + serviceIds?: string[]; + importance?: EventImportance; + types?: EventType[]; +} + +export interface ReplayParams { + matchId: string; + fromTime?: number; + toTime?: number; + resolution?: 'low' | 'medium' | 'high'; +} diff --git a/src/api/types/match.types.ts b/src/api/types/match.types.ts new file mode 100644 index 0000000..dd620dc --- /dev/null +++ b/src/api/types/match.types.ts @@ -0,0 +1,196 @@ +// Match types + +import { Team } from './team.types'; +import { Service } from './service.types'; +import { Round } from './round.types'; + +export interface Match { + id: string; + title: string; + description: string; + mode: MatchMode; + status: MatchStatus; + config: MatchConfig; + teams: MatchTeam[]; + services: Service[]; + rounds: Round[]; + currentRound: number; + totalRounds: number; + roundDuration: number; // seconds + startedAt: string | null; + finishedAt: string | null; + scheduledAt: string | null; + createdBy: string; + createdAt: string; + updatedAt: string; + seasonId: string | null; + isRanked: boolean; + visibility: MatchVisibility; +} + +export type MatchMode = 'training' | 'tournament' | 'scrim' | 'practice'; + +export type MatchStatus = + | 'draft' + | 'lobby' + | 'starting' + | 'running' + | 'paused' + | 'finished' + | 'cancelled' + | 'archived'; + +export type MatchVisibility = 'public' | 'private' | 'unlisted'; + +export interface MatchConfig { + maxTeams: number; + minTeams: number; + scoringPolicy: ScoringPolicy; + flagLifetime: number; // seconds + checkerTimeout: number; // seconds + checkerInterval: number; // seconds + enableHints: boolean; + enableReplay: boolean; + freezeTime: number; // minutes before end to freeze scoreboard + allowedServiceIds: string[]; + networkConfig: NetworkConfig; + penaltyConfig: PenaltyConfig; + flagFormat: string; // regex pattern + autoStart: boolean; + warmupDuration: number; // seconds +} + +export type ScoringPolicy = 'classic' | 'linear' | 'exponential' | 'dynamic'; + +export interface NetworkConfig { + teamSubnet: string; + servicePort: number; + vpnEnabled: boolean; + bandwidthLimit: number | null; +} + +export interface PenaltyConfig { + slaDownPenalty: number; + slaCorruptPenalty: number; + flagMissPenalty: number; + maxPenaltyPerRound: number; +} + +export interface MatchTeam { + id: string; + matchId: string; + team: Team; + slot: number; + status: MatchTeamStatus; + isReady: boolean; + joinedAt: string; + networkInfo: TeamNetworkInfo; +} + +export type MatchTeamStatus = 'registered' | 'ready' | 'playing' | 'finished' | 'disqualified'; + +export interface TeamNetworkInfo { + ip: string; + subnet: string; + vpnConfig: string | null; +} + +export interface MatchResult { + matchId: string; + match: Match; + rankings: MatchRanking[]; + duration: number; // seconds + totalRounds: number; + totalFlags: number; + stats: MatchStats; + highlights: MatchHighlight[]; + completedAt: string; +} + +export interface MatchRanking { + position: number; + team: Team; + score: number; + attackScore: number; + defenseScore: number; + slaScore: number; + flagsCaptured: number; + flagsLost: number; + avgSLA: number; + servicesUp: number; + servicesTotal: number; + trend: number; // position change from previous +} + +export interface MatchStats { + totalFlags: number; + totalAttacks: number; + successfulAttacks: number; + avgSLA: number; + mostActiveTeam: string; + mostAttackedService: string; + longestDowntime: number; + firstBlood: FirstBloodInfo | null; +} + +export interface FirstBloodInfo { + teamId: string; + teamName: string; + serviceId: string; + serviceName: string; + timestamp: string; + round: number; +} + +export interface MatchHighlight { + id: string; + type: HighlightType; + title: string; + description: string; + teamId: string; + round: number; + timestamp: string; + score: number; +} + +export type HighlightType = + | 'first_blood' + | 'comeback' + | 'dominant_round' + | 'perfect_defense' + | 'massive_attack' + | 'sla_recovery'; + +export interface CreateMatchRequest { + title: string; + description?: string; + mode: MatchMode; + config: Partial; + scheduledAt?: string; + visibility?: MatchVisibility; + isRanked?: boolean; + seasonId?: string; +} + +export interface UpdateMatchRequest { + title?: string; + description?: string; + config?: Partial; + scheduledAt?: string; + visibility?: MatchVisibility; +} + +export interface MatchListParams { + mode?: MatchMode; + status?: MatchStatus; + teamId?: string; + seasonId?: string; + isRanked?: boolean; + from?: string; + to?: string; +} + +export interface JoinMatchRequest { + teamId: string; + slot?: number; +} diff --git a/src/api/types/notification.types.ts b/src/api/types/notification.types.ts new file mode 100644 index 0000000..122dcd5 --- /dev/null +++ b/src/api/types/notification.types.ts @@ -0,0 +1,133 @@ +// Notification types + +export interface Notification { + id: string; + userId: string; + type: NotificationType; + category: NotificationCategory; + title: string; + message: string; + data: NotificationData; + isRead: boolean; + isArchived: boolean; + priority: NotificationPriority; + actionUrl: string | null; + actionText: string | null; + createdAt: string; + readAt: string | null; + expiresAt: string | null; +} + +export type NotificationType = + | 'match_invite' + | 'match_starting' + | 'match_ended' + | 'match_result' + | 'team_invite' + | 'team_join_request' + | 'team_member_joined' + | 'team_member_left' + | 'team_role_changed' + | 'flag_captured' + | 'service_down' + | 'achievement_unlocked' + | 'training_completed' + | 'new_exercise' + | 'streak_reminder' + | 'weekly_digest' + | 'system_announcement' + | 'maintenance' + | 'security_alert' + | 'rating_change' + | 'comment_reply' + | 'mention'; + +export type NotificationCategory = + | 'match' + | 'team' + | 'training' + | 'achievement' + | 'social' + | 'system' + | 'security'; + +export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'; + +export interface NotificationData { + matchId?: string; + matchTitle?: string; + teamId?: string; + teamName?: string; + userId?: string; + userName?: string; + serviceId?: string; + serviceName?: string; + achievementId?: string; + achievementName?: string; + trackId?: string; + trackName?: string; + exerciseId?: string; + exerciseName?: string; + score?: number; + position?: number; + rating?: number; + ratingChange?: number; + [key: string]: unknown; +} + +export interface NotificationPreferences { + email: NotificationChannelPrefs; + push: NotificationChannelPrefs; + inApp: NotificationChannelPrefs; +} + +export interface NotificationChannelPrefs { + enabled: boolean; + categories: { + [K in NotificationCategory]: boolean; + }; + quietHours: QuietHours | null; +} + +export interface QuietHours { + enabled: boolean; + from: string; // HH:mm + to: string; // HH:mm + timezone: string; +} + +export interface NotificationGroup { + date: string; + notifications: Notification[]; +} + +export interface NotificationStats { + total: number; + unread: number; + byCategory: Record; + byType: Record; +} + +export interface GetNotificationsParams { + category?: NotificationCategory; + type?: NotificationType; + isRead?: boolean; + isArchived?: boolean; + limit?: number; + offset?: number; + from?: string; + to?: string; +} + +export interface MarkNotificationsRequest { + notificationIds: string[]; + isRead?: boolean; + isArchived?: boolean; +} + +export interface NotificationEvent { + type: 'new' | 'read' | 'deleted'; + notification?: Notification; + notificationId?: string; + unreadCount: number; +} diff --git a/src/api/types/round.types.ts b/src/api/types/round.types.ts new file mode 100644 index 0000000..14f9dce --- /dev/null +++ b/src/api/types/round.types.ts @@ -0,0 +1,136 @@ +// Round types + +export interface Round { + id: string; + matchId: string; + number: number; + status: RoundStatus; + startedAt: string | null; + finishedAt: string | null; + duration: number; // actual duration in seconds + events: RoundEvent[]; + serviceChecks: ServiceCheck[]; + flagsGenerated: number; + flagsCaptured: number; + avgSLA: number; +} + +export type RoundStatus = 'pending' | 'active' | 'finished' | 'skipped'; + +export interface RoundEvent { + id: string; + roundId: string; + type: RoundEventType; + teamId: string | null; + targetTeamId: string | null; + serviceId: string | null; + data: RoundEventData; + timestamp: string; + round: number; +} + +export type RoundEventType = + | 'round_start' + | 'round_end' + | 'flag_generated' + | 'flag_captured' + | 'flag_expired' + | 'service_check' + | 'service_up' + | 'service_down' + | 'service_corrupt' + | 'attack_detected' + | 'exploit_blocked' + | 'score_update' + | 'penalty_applied' + | 'first_blood'; + +export interface RoundEventData { + flagId?: string; + flagValue?: string; + score?: number; + attackerTeamId?: string; + defenderTeamId?: string; + serviceId?: string; + serviceName?: string; + checkResult?: CheckerResult; + penaltyReason?: string; + penaltyAmount?: number; + message?: string; + details?: Record; +} + +export interface ServiceCheck { + id: string; + roundId: string; + serviceId: string; + teamId: string; + result: CheckerResult; + message: string | null; + latency: number; // ms + timestamp: string; +} + +export type CheckerResult = + | 'ok' + | 'corrupt' + | 'mumble' + | 'down' + | 'error' + | 'timeout'; + +export interface RoundSummary { + roundNumber: number; + teamScores: TeamRoundScore[]; + topAttacker: string | null; + topDefender: string | null; + flagsCaptured: number; + avgSLA: number; + duration: number; +} + +export interface TeamRoundScore { + teamId: string; + teamName: string; + score: number; + attackScore: number; + defenseScore: number; + slaScore: number; + flagsCaptured: number; + flagsLost: number; + servicesUp: number; + totalServices: number; +} + +export interface RoundTimeline { + roundNumber: number; + events: TimelineEvent[]; +} + +export interface TimelineEvent { + id: string; + type: RoundEventType; + timestamp: string; + relativeTime: number; // seconds from round start + teams: string[]; + service: string | null; + score: number | null; + icon: string; + color: string; + title: string; + description: string; +} + +export interface RoundParams { + matchId: string; + roundNumber?: number; +} + +export interface RoundEventsParams { + roundId: string; + types?: RoundEventType[]; + teamId?: string; + serviceId?: string; + limit?: number; + offset?: number; +} diff --git a/src/api/types/scoreboard.types.ts b/src/api/types/scoreboard.types.ts new file mode 100644 index 0000000..267f346 --- /dev/null +++ b/src/api/types/scoreboard.types.ts @@ -0,0 +1,138 @@ +// Scoreboard types + +import { ServiceStatusInfo } from './service.types'; + +export interface ScoreboardEntry { + position: number; + previousPosition: number; + positionChange: number; + teamId: string; + teamName: string; + teamTag: string; + teamAvatar: string | null; + score: number; + attackScore: number; + defenseScore: number; + slaScore: number; + breakdown: ScoreBreakdown; + serviceStatuses: ServiceStatusInfo[]; + flagsCaptured: number; + flagsLost: number; + avgSLA: number; + isCurrentTeam: boolean; + trend: ScoreTrend; +} + +export interface ScoreBreakdown { + services: ServiceScore[]; + totalAttack: number; + totalDefense: number; + totalSLA: number; + penalties: number; + bonuses: number; +} + +export interface ServiceScore { + serviceId: string; + serviceName: string; + attackScore: number; + defenseScore: number; + slaScore: number; + flagsCaptured: number; + flagsLost: number; + slaPercentage: number; + status: string; +} + +export type ScoreTrend = 'up' | 'down' | 'stable'; + +export interface ScoreHistory { + teamId: string; + teamName: string; + history: ScorePoint[]; +} + +export interface ScorePoint { + round: number; + timestamp: string; + score: number; + attackScore: number; + defenseScore: number; + slaScore: number; + position: number; +} + +export interface TeamScore { + teamId: string; + teamName: string; + score: number; + position: number; + breakdown: ScoreBreakdown; + history: ScorePoint[]; + lastUpdate: string; +} + +export interface ScoreboardData { + matchId: string; + matchTitle: string; + currentRound: number; + totalRounds: number; + roundEndsAt: string | null; + isFrozen: boolean; + frozenAt: string | null; + entries: ScoreboardEntry[]; + lastUpdate: string; +} + +export interface ScoreboardUpdate { + type: 'full' | 'partial'; + matchId: string; + round: number; + timestamp: string; + entries?: ScoreboardEntry[]; + updates?: PartialScoreUpdate[]; +} + +export interface PartialScoreUpdate { + teamId: string; + field: string; + value: number; + delta: number; +} + +export interface ScoreboardParams { + matchId: string; + round?: number; + includeHistory?: boolean; + includeBreakdown?: boolean; +} + +export interface ScoreHistoryParams { + matchId: string; + teamIds?: string[]; + fromRound?: number; + toRound?: number; + granularity?: 'round' | 'minute' | 'second'; +} + +export interface LeaderboardEntry { + position: number; + teamId: string; + teamName: string; + teamTag: string; + teamAvatar: string | null; + rating: number; + ratingChange: number; + matchesPlayed: number; + matchesWon: number; + winRate: number; + avgScore: number; + country: string | null; +} + +export interface SeasonLeaderboard { + seasonId: string; + seasonName: string; + entries: LeaderboardEntry[]; + lastUpdate: string; +} diff --git a/src/api/types/season.types.ts b/src/api/types/season.types.ts new file mode 100644 index 0000000..7466c7f --- /dev/null +++ b/src/api/types/season.types.ts @@ -0,0 +1,186 @@ +// Season and league types + +import { Team } from './team.types'; + +export interface Season { + id: string; + name: string; + slug: string; + description: string; + league: League; + status: SeasonStatus; + startDate: string; + endDate: string; + registrationStart: string; + registrationEnd: string; + config: SeasonConfig; + matches: SeasonMatch[]; + standings: SeasonStanding[]; + prizes: Prize[]; + rules: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export type SeasonStatus = + | 'draft' + | 'registration' + | 'active' + | 'playoffs' + | 'finished' + | 'archived'; + +export interface League { + id: string; + name: string; + slug: string; + description: string; + tier: LeagueTier; + icon: string; + color: string; + seasons: Season[]; + currentSeason: Season | null; + requirements: LeagueRequirements; +} + +export type LeagueTier = 'open' | 'amateur' | 'semi_pro' | 'professional' | 'elite'; + +export interface LeagueRequirements { + minRating: number; + minMatches: number; + minTeamSize: number; + maxTeamSize: number; + minTrainingProgress: number; +} + +export interface SeasonConfig { + format: SeasonFormat; + matchesPerWeek: number; + pointsForWin: number; + pointsForDraw: number; + pointsForLoss: number; + tiebreakers: string[]; + playoffTeams: number; + playoffFormat: PlayoffFormat; + rankingAlgorithm: RankingAlgorithm; +} + +export type SeasonFormat = 'round_robin' | 'swiss' | 'elimination' | 'double_elimination' | 'hybrid'; + +export type PlayoffFormat = 'single_elimination' | 'double_elimination' | 'best_of_3' | 'best_of_5'; + +export type RankingAlgorithm = 'elo' | 'glicko2' | 'trueskill' | 'points'; + +export interface SeasonMatch { + id: string; + seasonId: string; + matchId: string; + week: number; + round: string; + homeTeamId: string; + homeTeam: Team; + awayTeamId: string; + awayTeam: Team; + scheduledAt: string; + status: string; + result: SeasonMatchResult | null; +} + +export interface SeasonMatchResult { + homeScore: number; + awayScore: number; + homePoints: number; + awayPoints: number; + winnerId: string | null; + isDraw: boolean; +} + +export interface SeasonStanding { + position: number; + team: Team; + played: number; + won: number; + drawn: number; + lost: number; + pointsFor: number; + pointsAgainst: number; + pointsDiff: number; + seasonPoints: number; + form: string[]; // Last 5 results: W, L, D + streak: number; + streakType: 'win' | 'loss' | 'draw' | null; + qualifiedForPlayoffs: boolean; + eliminated: boolean; + rating: number; + ratingChange: number; +} + +export interface Prize { + position: number; + title: string; + description: string; + type: PrizeType; + value: number | null; + icon: string; +} + +export type PrizeType = 'cash' | 'points' | 'badge' | 'qualification' | 'merchandise' | 'other'; + +export interface SeasonSchedule { + seasonId: string; + weeks: ScheduleWeek[]; + playoffs: PlayoffBracket | null; +} + +export interface ScheduleWeek { + week: number; + startDate: string; + endDate: string; + matches: SeasonMatch[]; + isCurrentWeek: boolean; + isCompleted: boolean; +} + +export interface PlayoffBracket { + rounds: PlayoffRound[]; + final: SeasonMatch | null; + thirdPlace: SeasonMatch | null; +} + +export interface PlayoffRound { + name: string; + matches: SeasonMatch[]; +} + +export interface SeasonRegistration { + id: string; + seasonId: string; + teamId: string; + team: Team; + status: RegistrationStatus; + registeredAt: string; + processedAt: string | null; + processedBy: string | null; + rejectionReason: string | null; +} + +export type RegistrationStatus = 'pending' | 'approved' | 'rejected' | 'waitlisted' | 'withdrawn'; + +export interface RegisterForSeasonRequest { + seasonId: string; + teamId: string; + message?: string; +} + +export interface SeasonListParams { + leagueId?: string; + status?: SeasonStatus; + isActive?: boolean; + year?: number; +} + +export interface LeagueListParams { + tier?: LeagueTier; + search?: string; +} diff --git a/src/api/types/service.types.ts b/src/api/types/service.types.ts new file mode 100644 index 0000000..e288859 --- /dev/null +++ b/src/api/types/service.types.ts @@ -0,0 +1,240 @@ +// Service types + +import { User } from './auth.types'; +import { CheckerResult } from './round.types'; + +export interface Service { + id: string; + name: string; + slug: string; + description: string; + fullDescription: string; + category: ServiceCategory; + difficulty: ServiceDifficulty; + stack: string[]; + tags: string[]; + version: string; + author: User; + maintainers: User[]; + status: ServiceStatus; + rating: ServiceRating; + stats: ServiceStats; + config: ServiceConfig; + vulnerabilities: VulnerabilityInfo[]; + screenshots: string[]; + repository: string | null; + documentation: string | null; + createdAt: string; + updatedAt: string; + publishedAt: string | null; + isPublic: boolean; + isFeatured: boolean; +} + +export type ServiceCategory = + | 'web' + | 'crypto' + | 'pwn' + | 'reverse' + | 'forensics' + | 'network' + | 'misc' + | 'blockchain' + | 'hardware'; + +export type ServiceDifficulty = 'beginner' | 'easy' | 'medium' | 'hard' | 'expert' | 'insane'; + +export type ServiceStatus = + | 'draft' + | 'pending_review' + | 'under_review' + | 'needs_revision' + | 'approved' + | 'published' + | 'deprecated' + | 'archived'; + +export interface ServiceRating { + average: number; + count: number; + distribution: RatingDistribution; +} + +export interface RatingDistribution { + 1: number; + 2: number; + 3: number; + 4: number; + 5: number; +} + +export interface ServiceStats { + timesUsed: number; + uniqueTeams: number; + flagsCaptured: number; + avgExploitTime: number; // seconds + successRate: number; + avgSLA: number; + downloadsCount: number; +} + +export interface ServiceConfig { + port: number; + checkerImage: string; + serviceImage: string; + flagRegex: string; + flagPlacementMethods: string[]; + healthCheckEndpoint: string | null; + requiredResources: ResourceRequirements; + environment: Record; + volumes: VolumeMount[]; + networks: string[]; +} + +export interface ResourceRequirements { + cpu: string; + memory: string; + disk: string; +} + +export interface VolumeMount { + name: string; + mountPath: string; + readOnly: boolean; +} + +export interface VulnerabilityInfo { + id: string; + name: string; + type: VulnerabilityType; + difficulty: ServiceDifficulty; + description: string; + hint: string | null; + points: number; + isRequired: boolean; +} + +export type VulnerabilityType = + | 'sqli' + | 'xss' + | 'rce' + | 'lfi' + | 'rfi' + | 'ssrf' + | 'xxe' + | 'deserialization' + | 'buffer_overflow' + | 'format_string' + | 'race_condition' + | 'logic_flaw' + | 'crypto_weakness' + | 'auth_bypass' + | 'other'; + +export interface ServiceReview { + id: string; + serviceId: string; + userId: string; + user: User; + rating: number; + title: string; + content: string; + pros: string[]; + cons: string[]; + isVerified: boolean; // User actually played with this service + helpfulCount: number; + createdAt: string; + updatedAt: string; +} + +export interface ServiceVersion { + id: string; + serviceId: string; + version: string; + changelog: string; + isLatest: boolean; + isBroken: boolean; + publishedAt: string; + publishedBy: User; +} + +export interface ServiceValidation { + id: string; + serviceId: string; + status: ValidationStatus; + checks: ValidationCheck[]; + startedAt: string; + completedAt: string | null; + logs: string[]; + errorMessage: string | null; +} + +export type ValidationStatus = + | 'queued' + | 'running' + | 'passed' + | 'failed' + | 'cancelled'; + +export interface ValidationCheck { + name: string; + status: 'pending' | 'running' | 'passed' | 'failed' | 'skipped'; + message: string | null; + duration: number | null; +} + +export interface UploadServiceRequest { + name: string; + description: string; + fullDescription?: string; + category: ServiceCategory; + difficulty: ServiceDifficulty; + stack: string[]; + tags?: string[]; + config: Partial; + isPublic?: boolean; + repository?: string; +} + +export interface UpdateServiceRequest { + name?: string; + description?: string; + fullDescription?: string; + category?: ServiceCategory; + difficulty?: ServiceDifficulty; + stack?: string[]; + tags?: string[]; + config?: Partial; + isPublic?: boolean; + repository?: string; +} + +export interface ServiceSearchParams { + query?: string; + category?: ServiceCategory; + difficulty?: ServiceDifficulty; + stack?: string[]; + tags?: string[]; + status?: ServiceStatus; + minRating?: number; + authorId?: string; + isFeatured?: boolean; +} + +export interface CreateReviewRequest { + rating: number; + title: string; + content: string; + pros?: string[]; + cons?: string[]; +} + +export interface ServiceStatusInfo { + serviceId: string; + teamId: string; + status: CheckerResult; + lastCheck: string; + uptime: number; // percentage + consecutiveFailures: number; + message: string | null; +} diff --git a/src/api/types/team.types.ts b/src/api/types/team.types.ts new file mode 100644 index 0000000..5779be5 --- /dev/null +++ b/src/api/types/team.types.ts @@ -0,0 +1,168 @@ +// Team types + +import { User } from './auth.types'; + +export interface Team { + id: string; + name: string; + tag: string; // Short team tag (3-5 chars) + description: string; + avatar: string | null; + banner: string | null; + captain: TeamMember; + members: TeamMember[]; + maxMembers: number; + isPublic: boolean; + isRecruiting: boolean; + stats: TeamStats; + rank: number; + rating: number; + country: string | null; + socialLinks: TeamSocialLinks; + createdAt: string; + updatedAt: string; +} + +export interface TeamMember { + id: string; + userId: string; + user: User; + role: TeamRole; + specialization: TeamSpecialization | null; + joinedAt: string; + contribution: TeamContribution; + isActive: boolean; +} + +export type TeamRole = 'captain' | 'co-captain' | 'member' | 'substitute' | 'coach'; + +export type TeamSpecialization = + | 'attacker' + | 'defender' + | 'analyst' + | 'reverse_engineer' + | 'exploit_developer' + | 'infrastructure' + | 'generalist'; + +export interface TeamContribution { + matchesPlayed: number; + flagsCaptured: number; + flagsDefended: number; + scoreContributed: number; +} + +export interface TeamStats { + matchesPlayed: number; + matchesWon: number; + matchesLost: number; + winRate: number; + totalScore: number; + avgScore: number; + flagsCaptured: number; + flagsDefended: number; + avgSLA: number; + bestRank: number; + currentStreak: number; + longestStreak: number; +} + +export interface TeamSocialLinks { + website?: string; + discord?: string; + twitter?: string; + ctftime?: string; + github?: string; +} + +export interface TeamInvite { + id: string; + teamId: string; + team: Team; + invitedUserId: string; + invitedUser: User; + invitedByUserId: string; + invitedBy: User; + role: TeamRole; + message: string | null; + status: InviteStatus; + createdAt: string; + expiresAt: string; +} + +export type InviteStatus = 'pending' | 'accepted' | 'declined' | 'expired' | 'cancelled'; + +export interface TeamJoinRequest { + id: string; + teamId: string; + team: Team; + userId: string; + user: User; + message: string | null; + status: JoinRequestStatus; + createdAt: string; + processedAt: string | null; + processedBy: User | null; +} + +export type JoinRequestStatus = 'pending' | 'approved' | 'rejected' | 'cancelled'; + +export interface CreateTeamRequest { + name: string; + tag: string; + description?: string; + isPublic?: boolean; + isRecruiting?: boolean; + country?: string; +} + +export interface UpdateTeamRequest { + name?: string; + description?: string; + avatar?: string; + banner?: string; + isPublic?: boolean; + isRecruiting?: boolean; + maxMembers?: number; + socialLinks?: TeamSocialLinks; +} + +export interface InviteToTeamRequest { + userId: string; + role?: TeamRole; + message?: string; +} + +export interface JoinTeamRequest { + teamId: string; + message?: string; +} + +export interface UpdateMemberRoleRequest { + memberId: string; + role: TeamRole; + specialization?: TeamSpecialization; +} + +export interface TeamSearchParams { + query?: string; + isRecruiting?: boolean; + country?: string; + minRating?: number; + maxRating?: number; + minMembers?: number; + maxMembers?: number; +} + +export interface TeamMatchHistory { + matchId: string; + matchTitle: string; + mode: string; + position: number; + totalTeams: number; + score: number; + flagsCaptured: number; + flagsDefended: number; + sla: number; + playedAt: string; +} diff --git a/src/api/types/training.types.ts b/src/api/types/training.types.ts new file mode 100644 index 0000000..173dd7d --- /dev/null +++ b/src/api/types/training.types.ts @@ -0,0 +1,326 @@ +// Training types + +import { User } from './auth.types'; +import { ServiceCategory, ServiceDifficulty } from './service.types'; + +export interface Track { + id: string; + name: string; + slug: string; + description: string; + fullDescription: string; + role: TrackRole; + difficulty: ServiceDifficulty; + icon: string; + color: string; + skills: Skill[]; + exercises: Exercise[]; + totalExercises: number; + estimatedHours: number; + prerequisites: string[]; + learningOutcomes: string[]; + author: User; + rating: TrackRating; + enrolledCount: number; + completedCount: number; + tags: string[]; + isPublished: boolean; + isFeatured: boolean; + createdAt: string; + updatedAt: string; +} + +export type TrackRole = + | 'attacker' + | 'defender' + | 'analyst' + | 'reverse_engineer' + | 'exploit_developer' + | 'infrastructure' + | 'generalist' + | 'team_lead'; + +export interface TrackRating { + average: number; + count: number; +} + +export interface Skill { + id: string; + name: string; + slug: string; + description: string; + category: SkillCategory; + icon: string; + color: string; + maxLevel: number; + relatedSkills: string[]; +} + +export type SkillCategory = + | 'offensive' + | 'defensive' + | 'analysis' + | 'reverse_engineering' + | 'cryptography' + | 'networking' + | 'programming' + | 'infrastructure' + | 'soft_skills'; + +export interface Exercise { + id: string; + trackId: string; + title: string; + slug: string; + description: string; + content: string; // Markdown + type: ExerciseType; + difficulty: ServiceDifficulty; + category: ServiceCategory; + skills: Skill[]; + order: number; + estimatedMinutes: number; + points: number; + hints: Hint[]; + resources: Resource[]; + prerequisites: string[]; + successCriteria: SuccessCriteria; + environment: ExerciseEnvironment | null; + isLocked: boolean; + createdAt: string; + updatedAt: string; +} + +export type ExerciseType = + | 'theory' + | 'quiz' + | 'practical' + | 'challenge' + | 'ctf' + | 'project' + | 'assessment'; + +export interface Hint { + id: string; + order: number; + title: string; + content: string; + costPercentage: number; // Score reduction for using hint +} + +export interface Resource { + id: string; + type: ResourceType; + title: string; + url: string; + description: string; + isRequired: boolean; +} + +export type ResourceType = + | 'article' + | 'video' + | 'documentation' + | 'tool' + | 'writeup' + | 'book' + | 'course'; + +export interface SuccessCriteria { + type: 'flag' | 'quiz' | 'code' | 'manual' | 'auto'; + flagFormat?: string; + quizQuestions?: QuizQuestion[]; + codeTests?: CodeTest[]; + passingScore?: number; +} + +export interface QuizQuestion { + id: string; + question: string; + options: string[]; + correctIndex: number; + explanation: string; +} + +export interface CodeTest { + id: string; + name: string; + input: string; + expectedOutput: string; + timeLimit: number; + memoryLimit: number; +} + +export interface ExerciseEnvironment { + type: 'docker' | 'vm' | 'browser' | 'ssh'; + image: string; + ports: number[]; + credentials?: { + username: string; + password: string; + }; + files: EnvironmentFile[]; + timeLimit: number; // seconds +} + +export interface EnvironmentFile { + name: string; + path: string; + content: string; + isEditable: boolean; +} + +export interface ExerciseResult { + id: string; + exerciseId: string; + userId: string; + status: ExerciseStatus; + score: number; + maxScore: number; + hintsUsed: number; + totalHints: number; + attempts: number; + timeSpent: number; // seconds + startedAt: string; + completedAt: string | null; + submission: ExerciseSubmission | null; +} + +export type ExerciseStatus = + | 'not_started' + | 'in_progress' + | 'completed' + | 'failed' + | 'skipped'; + +export interface ExerciseSubmission { + id: string; + type: 'flag' | 'code' | 'quiz' | 'file'; + content: string; + isCorrect: boolean; + feedback: string; + submittedAt: string; +} + +export interface Progress { + userId: string; + trackId: string | null; + overallProgress: number; + totalPoints: number; + totalExercises: number; + completedExercises: number; + currentStreak: number; + longestStreak: number; + lastActivityAt: string; + trackProgress: TrackProgress[]; + skillLevels: SkillLevel[]; + achievements: string[]; + weeklyActivity: WeeklyActivity[]; +} + +export interface TrackProgress { + trackId: string; + trackName: string; + progress: number; + completedExercises: number; + totalExercises: number; + pointsEarned: number; + maxPoints: number; + startedAt: string; + lastActivityAt: string; + isCompleted: boolean; + certificate: string | null; +} + +export interface SkillLevel { + skillId: string; + skillName: string; + category: SkillCategory; + level: number; + maxLevel: number; + experience: number; + nextLevelExperience: number; +} + +export interface WeeklyActivity { + date: string; + exercisesCompleted: number; + pointsEarned: number; + minutesSpent: number; +} + +export interface StartExerciseRequest { + exerciseId: string; +} + +export interface StartExerciseResponse { + exerciseId: string; + sessionId: string; + environment: ExerciseEnvironment | null; + expiresAt: string; +} + +export interface SubmitExerciseRequest { + exerciseId: string; + type: 'flag' | 'code' | 'quiz' | 'file'; + content: string; +} + +export interface SubmitExerciseResponse { + success: boolean; + isCorrect: boolean; + score: number; + maxScore: number; + feedback: string; + nextExerciseId: string | null; +} + +export interface UseHintRequest { + exerciseId: string; + hintId: string; +} + +export interface UseHintResponse { + hint: Hint; + scorePenalty: number; + remainingHints: number; +} + +export interface TrackSearchParams { + query?: string; + role?: TrackRole; + difficulty?: ServiceDifficulty; + skills?: string[]; + tags?: string[]; + isFeatured?: boolean; + minRating?: number; +} + +export interface SkillMapData { + userId: string; + skills: SkillMapNode[]; + connections: SkillConnection[]; + recommendations: SkillRecommendation[]; +} + +export interface SkillMapNode { + skill: Skill; + level: number; + experience: number; + isUnlocked: boolean; + position: { x: number; y: number }; +} + +export interface SkillConnection { + fromSkillId: string; + toSkillId: string; + isUnlocked: boolean; +} + +export interface SkillRecommendation { + skillId: string; + reason: string; + priority: number; + suggestedExercises: string[]; +} diff --git a/src/api/websocket.ts b/src/api/websocket.ts new file mode 100644 index 0000000..ac266f8 --- /dev/null +++ b/src/api/websocket.ts @@ -0,0 +1,396 @@ +import { io, Socket } from 'socket.io-client'; +import { tokenStorage } from './axios'; +import { + ScoreboardUpdate, + RoundEvent, + LogEntry, + NotificationEvent +} from './types'; + +const WS_URL = import.meta.env.VITE_WS_URL || 'http://localhost:3001'; + +// Channel types +export type ChannelType = + | 'scoreboard' + | 'rounds' + | 'logs' + | 'notifications' + | 'match_status'; + +// Event handlers type +export interface WebSocketEvents { + // Scoreboard events + 'scoreboard:update': (data: ScoreboardUpdate) => void; + 'scoreboard:freeze': (data: { matchId: string; frozenAt: string }) => void; + 'scoreboard:unfreeze': (data: { matchId: string }) => void; + + // Round events + 'round:start': (data: RoundEvent) => void; + 'round:end': (data: RoundEvent) => void; + 'round:event': (data: RoundEvent) => void; + + // Log events + 'log:entry': (data: LogEntry) => void; + 'log:batch': (data: LogEntry[]) => void; + + // Match events + 'match:status': (data: { matchId: string; status: string; message?: string }) => void; + 'match:start': (data: { matchId: string; startedAt: string }) => void; + 'match:end': (data: { matchId: string; finishedAt: string }) => void; + 'match:pause': (data: { matchId: string }) => void; + 'match:resume': (data: { matchId: string }) => void; + + // Service events + 'service:status': (data: { matchId: string; teamId: string; serviceId: string; status: string }) => void; + 'service:check': (data: { matchId: string; teamId: string; serviceId: string; result: string }) => void; + + // Flag events + 'flag:captured': (data: { matchId: string; attackerId: string; defenderId: string; serviceId: string; points: number }) => void; + 'flag:generated': (data: { matchId: string; round: number; count: number }) => void; + + // Notification events + 'notification:new': (data: NotificationEvent) => void; + 'notification:read': (data: { notificationId: string }) => void; + + // Connection events + 'connect': () => void; + 'disconnect': (reason: string) => void; + 'connect_error': (error: Error) => void; + 'reconnect': (attemptNumber: number) => void; + 'reconnect_attempt': (attemptNumber: number) => void; + 'reconnect_error': (error: Error) => void; + 'reconnect_failed': () => void; +} + +type EventName = keyof WebSocketEvents; +type EventHandler = WebSocketEvents[E]; + +class WebSocketClient { + private socket: Socket | null = null; + private subscriptions: Map> = new Map(); + private eventHandlers: Map> = new Map(); + private reconnectAttempts = 0; + private maxReconnectAttempts = 10; + private reconnectDelay = 1000; + private isConnecting = false; + + // Connect to WebSocket server + connect(): Promise { + return new Promise((resolve, reject) => { + if (this.socket?.connected) { + resolve(); + return; + } + + if (this.isConnecting) { + // Wait for existing connection attempt + const checkConnection = setInterval(() => { + if (this.socket?.connected) { + clearInterval(checkConnection); + resolve(); + } + }, 100); + return; + } + + this.isConnecting = true; + const token = tokenStorage.getAccessToken(); + + this.socket = io(WS_URL, { + auth: { token }, + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: this.maxReconnectAttempts, + reconnectionDelay: this.reconnectDelay, + reconnectionDelayMax: 5000, + timeout: 20000, + }); + + this.socket.on('connect', () => { + console.log('[WS] Connected'); + this.isConnecting = false; + this.reconnectAttempts = 0; + this.resubscribeAll(); + this.emit('connect'); + resolve(); + }); + + this.socket.on('disconnect', (reason) => { + console.log('[WS] Disconnected:', reason); + this.emit('disconnect', reason); + }); + + this.socket.on('connect_error', (error) => { + console.error('[WS] Connection error:', error); + this.isConnecting = false; + this.emit('connect_error', error); + if (this.reconnectAttempts === 0) { + reject(error); + } + }); + + this.socket.on('reconnect', (attemptNumber) => { + console.log('[WS] Reconnected after', attemptNumber, 'attempts'); + this.emit('reconnect', attemptNumber); + }); + + this.socket.on('reconnect_attempt', (attemptNumber) => { + this.reconnectAttempts = attemptNumber; + this.emit('reconnect_attempt', attemptNumber); + }); + + this.socket.on('reconnect_error', (error) => { + console.error('[WS] Reconnect error:', error); + this.emit('reconnect_error', error); + }); + + this.socket.on('reconnect_failed', () => { + console.error('[WS] Reconnection failed'); + this.emit('reconnect_failed'); + }); + + // Setup all event listeners + this.setupEventListeners(); + }); + } + + // Disconnect from WebSocket server + disconnect(): void { + if (this.socket) { + this.socket.disconnect(); + this.socket = null; + } + this.subscriptions.clear(); + this.isConnecting = false; + } + + // Check if connected + isConnected(): boolean { + return this.socket?.connected ?? false; + } + + // Subscribe to a channel + subscribe(channel: ChannelType, id: string): void { + const channelKey = `${channel}:${id}`; + + if (!this.subscriptions.has(channel)) { + this.subscriptions.set(channel, new Set()); + } + this.subscriptions.get(channel)!.add(id); + + if (this.socket?.connected) { + this.socket.emit('subscribe', { channel, id }); + console.log('[WS] Subscribed to', channelKey); + } + } + + // Unsubscribe from a channel + unsubscribe(channel: ChannelType, id: string): void { + const channelKey = `${channel}:${id}`; + + this.subscriptions.get(channel)?.delete(id); + + if (this.socket?.connected) { + this.socket.emit('unsubscribe', { channel, id }); + console.log('[WS] Unsubscribed from', channelKey); + } + } + + // Resubscribe to all channels after reconnection + private resubscribeAll(): void { + this.subscriptions.forEach((ids, channel) => { + ids.forEach((id) => { + if (this.socket?.connected) { + this.socket.emit('subscribe', { channel, id }); + console.log('[WS] Resubscribed to', `${channel}:${id}`); + } + }); + }); + } + + // Add event handler + on(event: E, handler: EventHandler): void { + if (!this.eventHandlers.has(event)) { + this.eventHandlers.set(event, new Set()); + } + this.eventHandlers.get(event)!.add(handler); + } + + // Remove event handler + off(event: E, handler: EventHandler): void { + this.eventHandlers.get(event)?.delete(handler); + } + + // Emit to handlers + private emit(event: string, ...args: unknown[]): void { + this.eventHandlers.get(event)?.forEach((handler) => { + try { + handler(...args); + } catch (error) { + console.error(`[WS] Error in handler for ${event}:`, error); + } + }); + } + + // Setup all socket event listeners + private setupEventListeners(): void { + if (!this.socket) return; + + // Scoreboard events + this.socket.on('scoreboard:update', (data: ScoreboardUpdate) => { + this.emit('scoreboard:update', data); + }); + + this.socket.on('scoreboard:freeze', (data) => { + this.emit('scoreboard:freeze', data); + }); + + this.socket.on('scoreboard:unfreeze', (data) => { + this.emit('scoreboard:unfreeze', data); + }); + + // Round events + this.socket.on('round:start', (data: RoundEvent) => { + this.emit('round:start', data); + }); + + this.socket.on('round:end', (data: RoundEvent) => { + this.emit('round:end', data); + }); + + this.socket.on('round:event', (data: RoundEvent) => { + this.emit('round:event', data); + }); + + // Log events + this.socket.on('log:entry', (data: LogEntry) => { + this.emit('log:entry', data); + }); + + this.socket.on('log:batch', (data: LogEntry[]) => { + this.emit('log:batch', data); + }); + + // Match events + this.socket.on('match:status', (data) => { + this.emit('match:status', data); + }); + + this.socket.on('match:start', (data) => { + this.emit('match:start', data); + }); + + this.socket.on('match:end', (data) => { + this.emit('match:end', data); + }); + + this.socket.on('match:pause', (data) => { + this.emit('match:pause', data); + }); + + this.socket.on('match:resume', (data) => { + this.emit('match:resume', data); + }); + + // Service events + this.socket.on('service:status', (data) => { + this.emit('service:status', data); + }); + + this.socket.on('service:check', (data) => { + this.emit('service:check', data); + }); + + // Flag events + this.socket.on('flag:captured', (data) => { + this.emit('flag:captured', data); + }); + + this.socket.on('flag:generated', (data) => { + this.emit('flag:generated', data); + }); + + // Notification events + this.socket.on('notification:new', (data: NotificationEvent) => { + this.emit('notification:new', data); + }); + + this.socket.on('notification:read', (data) => { + this.emit('notification:read', data); + }); + } + + // Send message through socket + send(event: string, data: unknown): void { + if (this.socket?.connected) { + this.socket.emit(event, data); + } else { + console.warn('[WS] Cannot send message, not connected'); + } + } + + // Get socket instance (for advanced usage) + getSocket(): Socket | null { + return this.socket; + } +} + +// Export singleton instance +export const wsClient = new WebSocketClient(); + +// Export helper functions for common operations +export const wsHelpers = { + // Subscribe to match scoreboard updates + subscribeToScoreboard: (matchId: string) => { + wsClient.subscribe('scoreboard', matchId); + }, + + unsubscribeFromScoreboard: (matchId: string) => { + wsClient.unsubscribe('scoreboard', matchId); + }, + + // Subscribe to match round events + subscribeToRounds: (matchId: string) => { + wsClient.subscribe('rounds', matchId); + }, + + unsubscribeFromRounds: (matchId: string) => { + wsClient.unsubscribe('rounds', matchId); + }, + + // Subscribe to match logs + subscribeToLogs: (matchId: string) => { + wsClient.subscribe('logs', matchId); + }, + + unsubscribeFromLogs: (matchId: string) => { + wsClient.unsubscribe('logs', matchId); + }, + + // Subscribe to user notifications + subscribeToNotifications: (userId: string) => { + wsClient.subscribe('notifications', userId); + }, + + unsubscribeFromNotifications: (userId: string) => { + wsClient.unsubscribe('notifications', userId); + }, + + // Subscribe to match status changes + subscribeToMatchStatus: (matchId: string) => { + wsClient.subscribe('match_status', matchId); + }, + + unsubscribeFromMatchStatus: (matchId: string) => { + wsClient.unsubscribe('match_status', matchId); + }, + + // Connect and authenticate + connectWithAuth: async () => { + if (tokenStorage.hasTokens()) { + await wsClient.connect(); + } + }, +}; + +export default wsClient; diff --git a/src/components/analytics/AIRecommendationCard.tsx b/src/components/analytics/AIRecommendationCard.tsx new file mode 100644 index 0000000..42984b5 --- /dev/null +++ b/src/components/analytics/AIRecommendationCard.tsx @@ -0,0 +1,119 @@ +import { Lightbulb, TrendingUp, Target, BookOpen } from 'lucide-react'; +import { AIRecommendation } from '../../api/types'; +import { cn } from '../../utils/helpers'; + +interface AIRecommendationCardProps { + recommendation: AIRecommendation; + className?: string; + onDismiss?: () => void; +} + +const priorityColors = { + low: 'bg-blue-500/20 text-blue-400', + medium: 'bg-yellow-500/20 text-yellow-400', + high: 'bg-orange-500/20 text-orange-400', + critical: 'bg-red-500/20 text-red-400', +}; + +const typeIcons: Record> = { + skill_improvement: BookOpen, + strategy_change: TrendingUp, + service_focus: Target, + team_coordination: Lightbulb, + training_suggestion: BookOpen, + role_adjustment: Target, + tool_recommendation: Lightbulb, + resource_allocation: TrendingUp, +}; + +export function AIRecommendationCard({ + recommendation, + className, + onDismiss, +}: AIRecommendationCardProps) { + const Icon = typeIcons[recommendation.type] || Lightbulb; + const priorityColor = priorityColors[recommendation.priority]; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

{recommendation.title}

+

+ {recommendation.description} +

+
+
+ {onDismiss && ( + + )} +
+ + {/* Rationale */} + {recommendation.rationale && ( +

+ {recommendation.rationale} +

+ )} + + {/* Action items */} + {recommendation.actionItems.length > 0 && ( +
+
Действия:
+ {recommendation.actionItems.map((item) => ( +
+
+ {item.action} +
+ ))} +
+ )} + + {/* Footer */} +
+
+
+ Влияние:{' '} + {(recommendation.estimatedImpact * 100).toFixed(0)}% +
+
+ Усилия:{' '} + {(recommendation.estimatedEffort * 100).toFixed(0)}% +
+
+ Уверенность:{' '} + {(recommendation.confidence * 100).toFixed(0)}% +
+
+ + {recommendation.priority === 'low' && 'Низкий'} + {recommendation.priority === 'medium' && 'Средний'} + {recommendation.priority === 'high' && 'Высокий'} + {recommendation.priority === 'critical' && 'Критичный'} + +
+
+ ); +} + +export default AIRecommendationCard; diff --git a/src/components/analytics/AnalyticsCard.tsx b/src/components/analytics/AnalyticsCard.tsx new file mode 100644 index 0000000..a19e2e2 --- /dev/null +++ b/src/components/analytics/AnalyticsCard.tsx @@ -0,0 +1,57 @@ +import { LucideIcon } from 'lucide-react'; +import { cn } from '../../utils/helpers'; + +interface AnalyticsCardProps { + title: string; + value: string | number; + change?: number; + icon?: LucideIcon; + color?: string; + description?: string; + className?: string; +} + +export function AnalyticsCard({ + title, + value, + change, + icon: Icon, + color = 'text-primary', + description, + className, +}: AnalyticsCardProps) { + const isPositive = change && change > 0; + const isNegative = change && change < 0; + + return ( +
+
+
+

{title}

+

{value}

+ {change !== undefined && ( +

+ {isPositive ? '+' : ''}{change.toFixed(1)}% +

+ )} + {description && ( +

{description}

+ )} +
+ {Icon && ( +
+ +
+ )} +
+
+ ); +} + +export default AnalyticsCard; diff --git a/src/components/analytics/AttackHeatmap.tsx b/src/components/analytics/AttackHeatmap.tsx new file mode 100644 index 0000000..39db5b8 --- /dev/null +++ b/src/components/analytics/AttackHeatmap.tsx @@ -0,0 +1,97 @@ +import { cn } from '../../utils/helpers'; + +interface HeatmapCell { + round: number; + serviceId: string; + attacks: number; + successRate: number; +} + +interface AttackHeatmapProps { + data: HeatmapCell[]; + services: { id: string; name: string }[]; + rounds: number[]; + className?: string; +} + +export function AttackHeatmap({ + data, + services, + rounds, + className, +}: AttackHeatmapProps) { + const maxValue = Math.max(...data.map((d) => d.attacks), 1); + + const getColor = (attacks: number, successRate: number) => { + const intensity = attacks / maxValue; + const hue = 142 - (intensity * 100); // Green to Red + const saturation = 70 + (successRate * 30); + const lightness = 30 + (intensity * 30); + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; + }; + + const getCellValue = (round: number, serviceId: string) => { + return data.find((d) => d.round === round && d.serviceId === serviceId); + }; + + return ( +
+
+ {/* Header */} +
+
Сервис
+ {rounds.map((round) => ( +
+ R{round} +
+ ))} +
+ + {/* Rows */} + {services.map((service) => ( +
+
{service.name}
+ {rounds.map((round) => { + const cell = getCellValue(round, service.id); + return ( +
+ {cell ? cell.attacks : '-'} +
+ ); + })} +
+ ))} +
+ + {/* Legend */} +
+ Меньше +
+ Больше +
+
+ ); +} + +export default AttackHeatmap; diff --git a/src/components/analytics/PerformanceTrend.tsx b/src/components/analytics/PerformanceTrend.tsx new file mode 100644 index 0000000..184557a --- /dev/null +++ b/src/components/analytics/PerformanceTrend.tsx @@ -0,0 +1,112 @@ +import { useMemo } from 'react'; +import { TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import { cn } from '../../utils/helpers'; + +interface TrendPoint { + timestamp: string; + value: number; + label?: string; +} + +interface PerformanceTrendProps { + data: TrendPoint[]; + title?: string; + value?: number; + change?: number; + height?: number; + color?: string; + className?: string; +} + +export function PerformanceTrend({ + data, + title, + value, + change, + height = 60, + color = 'hsl(var(--primary))', + className, +}: PerformanceTrendProps) { + const trend = useMemo(() => { + if (data.length < 2) return 'stable'; + const first = data[0].value; + const last = data[data.length - 1].value; + if (last > first) return 'up'; + if (last < first) return 'down'; + return 'stable'; + }, [data]); + + const points = useMemo(() => { + if (data.length === 0) return ''; + const maxValue = Math.max(...data.map((d) => d.value)); + const minValue = Math.min(...data.map((d) => d.value)); + const range = maxValue - minValue || 1; + + return data + .map((d, i) => { + const x = (i / (data.length - 1 || 1)) * 100; + const y = 100 - ((d.value - minValue) / range) * 100; + return `${x},${y}`; + }) + .join(' '); + }, [data]); + + const areaPath = points ? `M 0,100 L ${points} L 100,100 Z` : ''; + + return ( +
+ {(title || value !== undefined) && ( +
+ {title && {title}} + {value !== undefined && ( +
+ {value} + {change !== undefined && ( + 0 && 'text-green-400', + change < 0 && 'text-red-400', + change === 0 && 'text-muted-foreground' + )} + > + {change > 0 ? ( + + ) : change < 0 ? ( + + ) : ( + + )} + {change > 0 && '+'} + {change.toFixed(1)}% + + )} +
+ )} +
+ )} + + {/* Sparkline */} +
+ + + + +
+
+ ); +} + +export default PerformanceTrend; diff --git a/src/components/analytics/SLAChart.tsx b/src/components/analytics/SLAChart.tsx new file mode 100644 index 0000000..e11cc8d --- /dev/null +++ b/src/components/analytics/SLAChart.tsx @@ -0,0 +1,160 @@ +import { useMemo } from 'react'; +import { cn } from '../../utils/helpers'; + +interface SLADataPoint { + round: number; + sla: number; + serviceName?: string; +} + +interface SLAChartProps { + data: SLADataPoint[]; + height?: number; + showGrid?: boolean; + showLabels?: boolean; + className?: string; + targetSLA?: number; +} + +export function SLAChart({ + data, + height = 200, + showGrid = true, + showLabels = true, + className, + targetSLA = 1.0, +}: SLAChartProps) { + const rounds = useMemo(() => data.map((d) => d.round), [data]); + const maxSLA = Math.max(...data.map((d) => d.sla), 1); + + const points = data + .map((d, i) => { + const x = (i / (data.length - 1 || 1)) * 100; + const y = 100 - (d.sla / maxSLA) * 100; + return `${x},${y}`; + }) + .join(' '); + + const areaPath = `M 0,100 L ${points} L 100,100 Z`; + + // Target line + const targetY = 100 - (targetSLA / maxSLA) * 100; + + return ( +
+ {/* Chart */} +
+ + {/* Grid */} + {showGrid && ( + <> + {[0, 25, 50, 75, 100].map((y) => ( + + ))} + + )} + + {/* Target line */} + + + {/* Area */} + + + {/* Line */} + + + {/* Points */} + {data.map((d, i) => { + const x = (i / (data.length - 1 || 1)) * 100; + const y = 100 - (d.sla / maxSLA) * 100; + const isBelowTarget = d.sla < targetSLA; + + return ( + + ); + })} + + + {/* Y-axis labels */} + {showLabels && ( +
+ 100% + 50% + 0% +
+ )} + + {/* X-axis labels */} + {showLabels && ( +
+ {rounds.slice(0, 5).map((round) => ( + R{round} + ))} + {rounds.length > 5 && ...} + {rounds.length > 1 && R{rounds[rounds.length - 1]}} +
+ )} +
+ + {/* Stats */} +
+
+
Средний SLA
+
+ {((data.reduce((acc, d) => acc + d.sla, 0) / data.length) * 100).toFixed(1)}% +
+
+
+
Минимум
+
+ {(Math.min(...data.map((d) => d.sla)) * 100).toFixed(1)}% +
+
+
+
Максимум
+
+ {(Math.max(...data.map((d) => d.sla)) * 100).toFixed(1)}% +
+
+
+
+ ); +} + +export default SLAChart; diff --git a/src/components/analytics/TeamComparisonChart.tsx b/src/components/analytics/TeamComparisonChart.tsx new file mode 100644 index 0000000..5a5a600 --- /dev/null +++ b/src/components/analytics/TeamComparisonChart.tsx @@ -0,0 +1,98 @@ +import { useMemo } from 'react'; +import { cn } from '../../utils/helpers'; + +interface ComparisonMetric { + name: string; + ourValue: number; + theirValue: number; + difference: number; + differencePercent: number; + winner: 'us' | 'them' | 'tie'; +} + +interface TeamComparisonChartProps { + metrics: ComparisonMetric[]; + ourTeamName?: string; + theirTeamName?: string; + className?: string; +} + +export function TeamComparisonChart({ + metrics, + ourTeamName = 'Наша команда', + theirTeamName = 'Соперник', + className, +}: TeamComparisonChartProps) { + const maxValue = useMemo(() => { + return Math.max( + ...metrics.map((m) => Math.max(m.ourValue, m.theirValue)) + ); + }, [metrics]); + + return ( +
+ {/* Header */} +
+
{ourTeamName}
+
Метрика
+
{theirTeamName}
+
+ + {/* Metrics */} + {metrics.map((metric) => { + const ourPercent = (metric.ourValue / maxValue) * 100; + const theirPercent = (metric.theirValue / maxValue) * 100; + + return ( +
+
{metric.name}
+
+ {/* Our bar */} +
+ + {metric.ourValue} + +
+
+
+
+ + {/* Metric name */} +
+ + {metric.differencePercent > 0 && '+'} + {metric.differencePercent.toFixed(1)}% + +
+ + {/* Their bar */} +
+
+
+
+ + {metric.theirValue} + +
+
+
+ ); + })} +
+ ); +} + +export default TeamComparisonChart; diff --git a/src/components/analytics/index.ts b/src/components/analytics/index.ts new file mode 100644 index 0000000..897d154 --- /dev/null +++ b/src/components/analytics/index.ts @@ -0,0 +1,7 @@ +// Export all analytics components +export { AnalyticsCard } from './AnalyticsCard'; +export { TeamComparisonChart } from './TeamComparisonChart'; +export { AttackHeatmap } from './AttackHeatmap'; +export { SLAChart } from './SLAChart'; +export { AIRecommendationCard } from './AIRecommendationCard'; +export { PerformanceTrend } from './PerformanceTrend'; diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..68efe99 --- /dev/null +++ b/src/components/auth/LoginForm.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Eye, EyeOff, Loader2 } from 'lucide-react'; +import { useAuthStore } from '../../store'; +import { loginSchema, LoginFormData } from '../../utils/validators'; +import { cn } from '../../utils/helpers'; + +interface LoginFormProps { + onSuccess?: () => void; + className?: string; +} + +export function LoginForm({ onSuccess, className }: LoginFormProps) { + const { login, isLoading, error, clearError } = useAuthStore(); + const [showPassword, setShowPassword] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + email: '', + password: '', + rememberMe: false, + }, + }); + + const onSubmit = async (data: LoginFormData) => { + try { + clearError(); + await login(data); + onSuccess?.(); + } catch { + // Error handled by store + } + }; + + return ( +
+ {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Email */} +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ + {/* Password */} +
+ +
+ + +
+ {errors.password && ( +

{errors.password.message}

+ )} +
+ + {/* Remember me */} +
+ + +
+ + {/* Submit */} + +
+ ); +} + +export default LoginForm; diff --git a/src/components/auth/ProtectedRoute.tsx b/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..d58af9d --- /dev/null +++ b/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,39 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuthStore } from '../../store'; +import { LoadingSpinner } from '../common/LoadingSpinner'; + +interface ProtectedRouteProps { + children: React.ReactNode; + requiredRoles?: string[]; +} + +export function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) { + const location = useLocation(); + const { isAuthenticated, isInitialized, isLoading, user } = useAuthStore(); + + // Show loading while checking auth + if (!isInitialized || isLoading) { + return ( +
+ +
+ ); + } + + // Redirect to login if not authenticated + if (!isAuthenticated) { + return ; + } + + // Check role requirements + if (requiredRoles && user) { + const hasRequiredRole = requiredRoles.includes(user.role); + if (!hasRequiredRole) { + return ; + } + } + + return <>{children}; +} + +export default ProtectedRoute; diff --git a/src/components/auth/RegisterForm.tsx b/src/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..85e0292 --- /dev/null +++ b/src/components/auth/RegisterForm.tsx @@ -0,0 +1,251 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Eye, EyeOff, Loader2, Check, X } from 'lucide-react'; +import { useAuthStore } from '../../store'; +import { registerSchema, RegisterFormData } from '../../utils/validators'; +import { cn } from '../../utils/helpers'; + +interface RegisterFormProps { + onSuccess?: () => void; + className?: string; +} + +export function RegisterForm({ onSuccess, className }: RegisterFormProps) { + const { register: registerUser, isLoading, error, clearError } = useAuthStore(); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + displayName: '', + acceptTerms: false, + }, + }); + + const password = watch('password'); + + const passwordRequirements = [ + { label: 'Минимум 8 символов', met: password?.length >= 8 }, + { label: 'Заглавная буква', met: /[A-Z]/.test(password || '') }, + { label: 'Строчная буква', met: /[a-z]/.test(password || '') }, + { label: 'Цифра', met: /[0-9]/.test(password || '') }, + { label: 'Специальный символ', met: /[^A-Za-z0-9]/.test(password || '') }, + ]; + + const onSubmit = async (data: RegisterFormData) => { + try { + clearError(); + await registerUser(data); + onSuccess?.(); + } catch { + // Error handled by store + } + }; + + return ( +
+ {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Username */} +
+ + + {errors.username && ( +

{errors.username.message}

+ )} +
+ + {/* Email */} +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ + {/* Display Name (optional) */} +
+ + + {errors.displayName && ( +

{errors.displayName.message}

+ )} +
+ + {/* Password */} +
+ +
+ + +
+ {/* Password requirements */} + {password && ( +
+ {passwordRequirements.map((req) => ( +
+ {req.met ? : } + {req.label} +
+ ))} +
+ )} + {errors.password && ( +

{errors.password.message}

+ )} +
+ + {/* Confirm Password */} +
+ +
+ + +
+ {errors.confirmPassword && ( +

{errors.confirmPassword.message}

+ )} +
+ + {/* Accept Terms */} + + {errors.acceptTerms && ( +

{errors.acceptTerms.message}

+ )} + + {/* Submit */} + +
+ ); +} + +export default RegisterForm; diff --git a/src/components/auth/RoleGuard.tsx b/src/components/auth/RoleGuard.tsx new file mode 100644 index 0000000..2719c54 --- /dev/null +++ b/src/components/auth/RoleGuard.tsx @@ -0,0 +1,79 @@ +import { Navigate } from 'react-router-dom'; +import { useAuthStore } from '../../store'; +import { UserRole } from '../../api/types'; +import { LoadingSpinner } from '../common/LoadingSpinner'; + +interface RoleGuardProps { + children: React.ReactNode; + allowedRoles: UserRole[]; + fallback?: React.ReactNode; + redirectTo?: string; +} + +export function RoleGuard({ + children, + allowedRoles, + fallback, + redirectTo = '/dashboard', +}: RoleGuardProps) { + const { user, isLoading, isInitialized } = useAuthStore(); + + // Show loading while checking auth + if (!isInitialized || isLoading) { + return ( +
+ +
+ ); + } + + // Check if user has required role + if (!user || !allowedRoles.includes(user.role)) { + if (fallback) { + return <>{fallback}; + } + return ; + } + + return <>{children}; +} + +/** + * Admin-only guard + */ +export function AdminGuard({ + children, + fallback, + redirectTo, +}: Omit) { + return ( + + {children} + + ); +} + +/** + * Organizer or admin guard + */ +export function OrganizerGuard({ + children, + fallback, + redirectTo, +}: Omit) { + return ( + + {children} + + ); +} + +export default RoleGuard; diff --git a/src/components/auth/index.ts b/src/components/auth/index.ts new file mode 100644 index 0000000..2946568 --- /dev/null +++ b/src/components/auth/index.ts @@ -0,0 +1,5 @@ +// Export all auth components +export { ProtectedRoute } from './ProtectedRoute'; +export { LoginForm } from './LoginForm'; +export { RegisterForm } from './RegisterForm'; +export { RoleGuard, AdminGuard, OrganizerGuard } from './RoleGuard'; diff --git a/src/components/common/Breadcrumbs.tsx b/src/components/common/Breadcrumbs.tsx new file mode 100644 index 0000000..82b32b2 --- /dev/null +++ b/src/components/common/Breadcrumbs.tsx @@ -0,0 +1,115 @@ +import { Link, useLocation } from 'react-router-dom'; +import { ChevronRight, Home } from 'lucide-react'; +import { cn } from '../../utils/helpers'; + +interface BreadcrumbItem { + label: string; + href?: string; +} + +interface BreadcrumbsProps { + items?: BreadcrumbItem[]; + showHome?: boolean; + className?: string; + separator?: React.ReactNode; +} + +// Default route labels +const routeLabels: Record = { + dashboard: 'Обзор', + matches: 'Матчи', + scoreboard: 'Рейтинг', + services: 'Сервисы', + teams: 'Команды', + training: 'Обучение', + analytics: 'Аналитика', + logs: 'Логи', + profile: 'Профиль', + settings: 'Настройки', + admin: 'Администрирование', + seasons: 'Сезоны', + live: 'Прямой эфир', + lobby: 'Лобби', + results: 'Результаты', + exercises: 'Упражнения', + team: 'Команда', + player: 'Игрок', + users: 'Пользователи', + system: 'Система', +}; + +export function Breadcrumbs({ + items, + showHome = true, + className, + separator, +}: BreadcrumbsProps) { + const location = useLocation(); + + // Generate breadcrumbs from current path if items not provided + const breadcrumbItems: BreadcrumbItem[] = items || (() => { + const pathSegments = location.pathname.split('/').filter(Boolean); + return pathSegments.map((segment, index) => { + const href = '/' + pathSegments.slice(0, index + 1).join('/'); + const label = routeLabels[segment] || segment.charAt(0).toUpperCase() + segment.slice(1); + return { label, href }; + }); + })(); + + const defaultSeparator = ( + + ); + + return ( + + ); +} + +export default Breadcrumbs; diff --git a/src/components/common/ConfirmDialog.tsx b/src/components/common/ConfirmDialog.tsx new file mode 100644 index 0000000..9315095 --- /dev/null +++ b/src/components/common/ConfirmDialog.tsx @@ -0,0 +1,150 @@ +import { useState, useCallback, createContext, useContext, ReactNode } from 'react'; +import { AlertTriangle, X } from 'lucide-react'; +import { cn } from '../../utils/helpers'; + +interface ConfirmDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void | Promise; + title: string; + description?: string; + confirmText?: string; + cancelText?: string; + variant?: 'default' | 'destructive'; + loading?: boolean; +} + +export function ConfirmDialog({ + open, + onClose, + onConfirm, + title, + description, + confirmText = 'Подтвердить', + cancelText = 'Отмена', + variant = 'default', + loading = false, +}: ConfirmDialogProps) { + const [isLoading, setIsLoading] = useState(false); + + const handleConfirm = useCallback(async () => { + setIsLoading(true); + try { + await onConfirm(); + onClose(); + } finally { + setIsLoading(false); + } + }, [onConfirm, onClose]); + + if (!open) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+ {/* Close button */} + + + {/* Icon */} + {variant === 'destructive' && ( +
+ +
+ )} + + {/* Content */} +

{title}

+ {description && ( +

{description}

+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +} + +// Context for confirm dialogs +interface ConfirmContextType { + confirm: (options: Omit & { + onConfirm: () => void | Promise; + }) => Promise; +} + +const ConfirmContext = createContext(null); + +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [dialogProps, setDialogProps] = useState(null); + + const confirm = useCallback(( + options: Omit & { + onConfirm: () => void | Promise; + } + ): Promise => { + return new Promise((resolve) => { + setDialogProps({ + ...options, + open: true, + onClose: () => { + setDialogProps(null); + resolve(false); + }, + onConfirm: async () => { + await options.onConfirm(); + setDialogProps(null); + resolve(true); + }, + }); + }); + }, []); + + return ( + + {children} + {dialogProps && } + + ); +} + +export function useConfirm() { + const context = useContext(ConfirmContext); + if (!context) { + throw new Error('useConfirm must be used within ConfirmProvider'); + } + return context.confirm; +} + +export default ConfirmDialog; diff --git a/src/components/common/CopyButton.tsx b/src/components/common/CopyButton.tsx new file mode 100644 index 0000000..aae0498 --- /dev/null +++ b/src/components/common/CopyButton.tsx @@ -0,0 +1,85 @@ +import { useState, useCallback } from 'react'; +import { Copy, Check } from 'lucide-react'; +import { cn } from '../../utils/helpers'; +import { copyToClipboard } from '../../utils/helpers'; +import toast from 'react-hot-toast'; + +interface CopyButtonProps { + value: string; + className?: string; + successMessage?: string; + showToast?: boolean; + size?: 'sm' | 'md' | 'lg'; + variant?: 'default' | 'ghost' | 'outline'; + children?: React.ReactNode; +} + +const sizeClasses = { + sm: 'h-7 w-7', + md: 'h-8 w-8', + lg: 'h-10 w-10', +}; + +const iconSizes = { + sm: 'h-3 w-3', + md: 'h-4 w-4', + lg: 'h-5 w-5', +}; + +const variantClasses = { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + ghost: 'hover:bg-accent hover:text-accent-foreground', + outline: 'border border-border bg-background hover:bg-accent hover:text-accent-foreground', +}; + +export function CopyButton({ + value, + className, + successMessage = 'Скопировано!', + showToast = true, + size = 'md', + variant = 'ghost', + children, +}: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + const success = await copyToClipboard(value); + + if (success) { + setCopied(true); + if (showToast) { + toast.success(successMessage); + } + setTimeout(() => setCopied(false), 2000); + } else { + if (showToast) { + toast.error('Не удалось скопировать'); + } + } + }, [value, successMessage, showToast]); + + return ( + + ); +} + +export default CopyButton; diff --git a/src/components/common/Countdown.tsx b/src/components/common/Countdown.tsx new file mode 100644 index 0000000..157258a --- /dev/null +++ b/src/components/common/Countdown.tsx @@ -0,0 +1,133 @@ +import { useMatchTimer } from '../../hooks/useMatchTimer'; +import { cn } from '../../utils/helpers'; + +interface CountdownProps { + /** Duration in seconds */ + duration: number; + /** Auto-start the countdown */ + autoStart?: boolean; + /** Callback when countdown reaches zero */ + onComplete?: () => void; + /** Show hours even if zero */ + showHours?: boolean; + /** Size variant */ + size?: 'sm' | 'md' | 'lg' | 'xl'; + /** Style variant */ + variant?: 'default' | 'danger' | 'warning' | 'success'; + /** Additional className */ + className?: string; + /** Show progress bar */ + showProgress?: boolean; + /** Separator between time parts */ + separator?: string; +} + +const sizeClasses = { + sm: 'text-lg', + md: 'text-2xl', + lg: 'text-4xl', + xl: 'text-6xl', +}; + +const variantClasses = { + default: 'text-foreground', + danger: 'text-destructive', + warning: 'text-yellow-500', + success: 'text-green-500', +}; + +export function Countdown({ + duration, + autoStart = true, + onComplete, + showHours = false, + size = 'md', + variant = 'default', + className, + showProgress = false, + separator = ':', +}: CountdownProps) { + const { remaining, progress, parts, isCompleted } = useMatchTimer({ + duration, + autoStart, + onComplete, + }); + + // Determine variant based on remaining time + const dynamicVariant = (() => { + if (variant !== 'default') return variant; + if (remaining <= 10) return 'danger'; + if (remaining <= 60) return 'warning'; + return 'default'; + })(); + + const formatPart = (value: number) => value.toString().padStart(2, '0'); + + return ( +
+
+ {(showHours || parts.hours > 0) && ( + <> + {formatPart(parts.hours)} + {separator} + + )} + {formatPart(parts.minutes)} + {separator} + {formatPart(parts.seconds)} +
+ + {showProgress && ( +
+
+
+ )} +
+ ); +} + +/** + * Inline countdown for text + */ +export function InlineCountdown({ + duration, + autoStart = true, + className +}: { + duration: number; + autoStart?: boolean; + className?: string; +}) { + const { formatted, isCompleted } = useMatchTimer({ + duration, + autoStart, + }); + + return ( + + {formatted} + + ); +} + +export default Countdown; diff --git a/src/components/common/DataTable.tsx b/src/components/common/DataTable.tsx new file mode 100644 index 0000000..923bff3 --- /dev/null +++ b/src/components/common/DataTable.tsx @@ -0,0 +1,267 @@ +import { useState } from 'react'; +import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight } from 'lucide-react'; +import { cn } from '../../utils/helpers'; +import { Skeleton } from './LoadingSkeleton'; +import { EmptyState } from './EmptyState'; + +// Simplified column definition +export interface ColumnDef { + id: string; + header: string; + accessorKey?: keyof T; + accessorFn?: (row: T) => unknown; + cell?: (info: { row: T; getValue: () => unknown }) => React.ReactNode; + sortable?: boolean; + width?: string; +} + +interface DataTableProps { + columns: ColumnDef[]; + data: T[]; + isLoading?: boolean; + emptyMessage?: string; + emptyDescription?: string; + pageSize?: number; + showPagination?: boolean; + enableSorting?: boolean; + className?: string; + striped?: boolean; + compact?: boolean; + onRowClick?: (row: T) => void; + getRowId?: (row: T) => string; +} + +type SortDirection = 'asc' | 'desc' | null; + +interface SortState { + columnId: string | null; + direction: SortDirection; +} + +export function DataTable({ + columns, + data, + isLoading = false, + emptyMessage = 'Нет данных', + emptyDescription, + pageSize = 10, + showPagination = true, + enableSorting = true, + className, + striped = false, + compact = false, + onRowClick, + getRowId, +}: DataTableProps) { + const [sort, setSort] = useState({ columnId: null, direction: null }); + const [currentPage, setCurrentPage] = useState(0); + + // Get value from row + const getValue = (row: T, column: ColumnDef): unknown => { + if (column.accessorFn) return column.accessorFn(row); + if (column.accessorKey) return row[column.accessorKey]; + return null; + }; + + // Sort data + const sortedData = [...data].sort((a, b) => { + if (!sort.columnId || !sort.direction) return 0; + + const column = columns.find((c) => c.id === sort.columnId); + if (!column) return 0; + + const aVal = getValue(a, column); + const bVal = getValue(b, column); + + if (aVal === bVal) return 0; + if (aVal === null || aVal === undefined) return 1; + if (bVal === null || bVal === undefined) return -1; + + const comparison = aVal < bVal ? -1 : 1; + return sort.direction === 'asc' ? comparison : -comparison; + }); + + // Paginate data + const totalPages = Math.ceil(sortedData.length / pageSize); + const paginatedData = showPagination + ? sortedData.slice(currentPage * pageSize, (currentPage + 1) * pageSize) + : sortedData; + + // Toggle sort + const toggleSort = (columnId: string) => { + if (!enableSorting) return; + + setSort((prev) => { + if (prev.columnId !== columnId) { + return { columnId, direction: 'asc' }; + } + if (prev.direction === 'asc') { + return { columnId, direction: 'desc' }; + } + return { columnId: null, direction: null }; + }); + }; + + if (isLoading) { + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {Array.from({ length: 5 }).map((_, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ +
+ +
+
+ ); + } + + if (data.length === 0) { + return ( + + ); + } + + return ( +
+
+ + + + {columns.map((column) => ( + + ))} + + + + {paginatedData.map((row, index) => ( + onRowClick?.(row)} + > + {columns.map((column) => { + const value = getValue(row, column); + return ( + + ); + })} + + ))} + +
+
column.sortable !== false && toggleSort(column.id)} + > + {column.header} + {column.sortable !== false && enableSorting && ( + + {sort.columnId === column.id && sort.direction === 'asc' ? ( + + ) : sort.columnId === column.id && sort.direction === 'desc' ? ( + + ) : ( + + )} + + )} +
+
+ {column.cell + ? column.cell({ row, getValue: () => value }) + : String(value ?? '')} +
+
+ + {showPagination && totalPages > 1 && ( +
+
+ Страница {currentPage + 1} из {totalPages} +
+
+ + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + let pageNum: number; + if (totalPages <= 5) { + pageNum = i; + } else if (currentPage < 3) { + pageNum = i; + } else if (currentPage > totalPages - 4) { + pageNum = totalPages - 5 + i; + } else { + pageNum = currentPage - 2 + i; + } + return ( + + ); + })} + +
+
+ )} +
+ ); +} + +export default DataTable; diff --git a/src/components/common/EmptyState.tsx b/src/components/common/EmptyState.tsx new file mode 100644 index 0000000..07928d8 --- /dev/null +++ b/src/components/common/EmptyState.tsx @@ -0,0 +1,42 @@ +import { cn } from '../../utils/helpers'; +import { LucideIcon } from 'lucide-react'; + +interface EmptyStateProps { + icon?: LucideIcon; + title: string; + description?: string; + action?: React.ReactNode; + className?: string; +} + +export function EmptyState({ + icon: Icon, + title, + description, + action, + className, +}: EmptyStateProps) { + return ( +
+ {Icon && ( +
+ +
+ )} +

{title}

+ {description && ( +

+ {description} +

+ )} + {action &&
{action}
} +
+ ); +} + +export default EmptyState; diff --git a/src/components/common/ErrorBoundary.tsx b/src/components/common/ErrorBoundary.tsx new file mode 100644 index 0000000..309383a --- /dev/null +++ b/src/components/common/ErrorBoundary.tsx @@ -0,0 +1,69 @@ +import { Component, ErrorInfo, ReactNode } from 'react'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Error caught by boundary:', error, errorInfo); + this.props.onError?.(error, errorInfo); + } + + private handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + public render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+ +
+

Что-то пошло не так

+

+ Произошла непредвиденная ошибка. Попробуйте обновить страницу. +

+ {this.state.error && ( +
+              {this.state.error.message}
+            
+ )} + +
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/components/common/LoadingSkeleton.tsx b/src/components/common/LoadingSkeleton.tsx new file mode 100644 index 0000000..82dd38e --- /dev/null +++ b/src/components/common/LoadingSkeleton.tsx @@ -0,0 +1,120 @@ +import { cn } from '../../utils/helpers'; + +interface SkeletonProps { + className?: string; +} + +export function Skeleton({ className }: SkeletonProps) { + return ( +
+ ); +} + +// Common skeleton patterns +export function CardSkeleton() { + return ( +
+ + + + +
+ ); +} + +export function TableRowSkeleton({ columns = 5 }: { columns?: number }) { + return ( + + {Array.from({ length: columns }).map((_, i) => ( + + + + ))} + + ); +} + +export function TableSkeleton({ rows = 5, columns = 5 }: { rows?: number; columns?: number }) { + return ( +
+ + + + {Array.from({ length: columns }).map((_, i) => ( + + ))} + + + + {Array.from({ length: rows }).map((_, i) => ( + + ))} + +
+ +
+
+ ); +} + +export function ListSkeleton({ items = 5 }: { items?: number }) { + return ( +
+ {Array.from({ length: items }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ); +} + +export function ProfileSkeleton() { + return ( +
+
+ +
+ + +
+ + +
+
+
+
+ + + +
+
+ ); +} + +export function StatsSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+ ); +} + +export { Skeleton as LoadingSkeleton }; +export default Skeleton; diff --git a/src/components/common/LoadingSpinner.tsx b/src/components/common/LoadingSpinner.tsx new file mode 100644 index 0000000..0f791be --- /dev/null +++ b/src/components/common/LoadingSpinner.tsx @@ -0,0 +1,44 @@ +import { cn } from '../../utils/helpers'; + +interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg' | 'xl'; + className?: string; +} + +const sizeClasses = { + sm: 'h-4 w-4 border-2', + md: 'h-8 w-8 border-2', + lg: 'h-12 w-12 border-3', + xl: 'h-16 w-16 border-4', +}; + +export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) { + return ( +
+ ); +} + +interface LoadingOverlayProps { + message?: string; +} + +export function LoadingOverlay({ message }: LoadingOverlayProps) { + return ( +
+
+ + {message && ( +

{message}

+ )} +
+
+ ); +} + +export default LoadingSpinner; diff --git a/src/components/common/MarkdownRenderer.tsx b/src/components/common/MarkdownRenderer.tsx new file mode 100644 index 0000000..8c9e653 --- /dev/null +++ b/src/components/common/MarkdownRenderer.tsx @@ -0,0 +1,126 @@ +import { useMemo } from 'react'; +import { cn } from '../../utils/helpers'; + +interface MarkdownRendererProps { + content: string; + className?: string; +} + +// Simple markdown parser (for basic formatting) +// For production, consider using react-markdown or similar library +function parseMarkdown(text: string): string { + let html = text; + + // Escape HTML + html = html + .replace(/&/g, '&') + .replace(//g, '>'); + + // Headers + html = html.replace(/^### (.*$)/gim, '

$1

'); + html = html.replace(/^## (.*$)/gim, '

$1

'); + html = html.replace(/^# (.*$)/gim, '

$1

'); + + // Bold and italic + html = html.replace(/\*\*\*(.*?)\*\*\*/g, '$1'); + html = html.replace(/\*\*(.*?)\*\*/g, '$1'); + html = html.replace(/\*(.*?)\*/g, '$1'); + html = html.replace(/___(.*?)___/g, '$1'); + html = html.replace(/__(.*?)__/g, '$1'); + html = html.replace(/_(.*?)_/g, '$1'); + + // Inline code + html = html.replace(/`([^`]+)`/g, '$1'); + + // Code blocks + html = html.replace( + /```(\w+)?\n([\s\S]*?)```/g, + '
$2
' + ); + + // Links + html = html.replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + '$1' + ); + + // Images + html = html.replace( + /!\[([^\]]*)\]\(([^)]+)\)/g, + '$1' + ); + + // Unordered lists + html = html.replace(/^\s*[-*+]\s+(.*)$/gim, '
  • $1
  • '); + html = html.replace(/(\n?)+/g, '
      $&
    '); + + // Ordered lists + html = html.replace(/^\s*\d+\.\s+(.*)$/gim, '
  • $1
  • '); + + // Blockquotes + html = html.replace( + /^>\s*(.*)$/gim, + '
    $1
    ' + ); + + // Horizontal rules + html = html.replace(/^---+$/gim, '
    '); + html = html.replace(/^\*\*\*+$/gim, '
    '); + + // Line breaks (two spaces at end of line) + html = html.replace(/ $/gim, '
    '); + + // Paragraphs + html = html.replace(/\n\n/g, '

    '); + html = '

    ' + html + '

    '; + + // Clean up empty paragraphs + html = html.replace(/

    \s*<\/p>/g, ''); + html = html.replace(/

    ()<\/p>/g, '$1'); + html = html.replace(/

    ()<\/p>/g, '$1'); + html = html.replace(/

    ()<\/p>/g, '$1'); + html = html.replace(/

    ()<\/p>/g, '$1'); + html = html.replace(/

    ( parseMarkdown(content), [content]); + + return ( +

    + ); +} + +// Simple text renderer (no HTML, just clean text) +export function MarkdownToText({ content }: { content: string }): string { + return content + .replace(/\*\*\*(.*?)\*\*\*/g, '$1') + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/___(.*?)___/g, '$1') + .replace(/__(.*?)__/g, '$1') + .replace(/_(.*?)_/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/```[\s\S]*?```/g, '') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/!\[([^\]]*)\]\([^)]+\)/g, '') + .replace(/^#+\s*/gm, '') + .replace(/^[-*+]\s+/gm, '• ') + .replace(/^\d+\.\s+/gm, '') + .replace(/^>\s*/gm, '') + .replace(/---+/g, '') + .replace(/\*\*\*+/g, '') + .trim(); +} + +export default MarkdownRenderer; diff --git a/src/components/common/SearchInput.tsx b/src/components/common/SearchInput.tsx new file mode 100644 index 0000000..568d546 --- /dev/null +++ b/src/components/common/SearchInput.tsx @@ -0,0 +1,119 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Search, X } from 'lucide-react'; +import { cn } from '../../utils/helpers'; +import { useDebounce } from '../../hooks/useDebounce'; + +interface SearchInputProps { + value?: string; + onChange?: (value: string) => void; + onSearch?: (value: string) => void; + placeholder?: string; + debounceMs?: number; + className?: string; + autoFocus?: boolean; + showClearButton?: boolean; + size?: 'sm' | 'md' | 'lg'; +} + +const sizeClasses = { + sm: 'h-8 text-sm pl-8 pr-8', + md: 'h-10 text-sm pl-10 pr-10', + lg: 'h-12 text-base pl-12 pr-12', +}; + +const iconSizes = { + sm: 'h-4 w-4', + md: 'h-4 w-4', + lg: 'h-5 w-5', +}; + +const iconPositions = { + sm: 'left-2', + md: 'left-3', + lg: 'left-4', +}; + +export function SearchInput({ + value: externalValue, + onChange, + onSearch, + placeholder = 'Поиск...', + debounceMs = 300, + className, + autoFocus = false, + showClearButton = true, + size = 'md', +}: SearchInputProps) { + const [internalValue, setInternalValue] = useState(externalValue || ''); + const debouncedValue = useDebounce(internalValue, debounceMs); + + // Sync with external value + useEffect(() => { + if (externalValue !== undefined) { + setInternalValue(externalValue); + } + }, [externalValue]); + + // Call onSearch when debounced value changes + useEffect(() => { + onSearch?.(debouncedValue); + }, [debouncedValue, onSearch]); + + const handleChange = useCallback((e: React.ChangeEvent) => { + const newValue = e.target.value; + setInternalValue(newValue); + onChange?.(newValue); + }, [onChange]); + + const handleClear = useCallback(() => { + setInternalValue(''); + onChange?.(''); + onSearch?.(''); + }, [onChange, onSearch]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + handleClear(); + } + }, [handleClear]); + + return ( +
    + + + {showClearButton && internalValue && ( + + )} +
    + ); +} + +export default SearchInput; diff --git a/src/components/common/StatusBadge.tsx b/src/components/common/StatusBadge.tsx new file mode 100644 index 0000000..a2849c2 --- /dev/null +++ b/src/components/common/StatusBadge.tsx @@ -0,0 +1,152 @@ +import { cn } from '../../utils/helpers'; +import { + MATCH_STATUS_LABELS, + CHECKER_RESULT_LABELS, + DIFFICULTY_LABELS, +} from '../../utils/constants'; +// Colors are defined inline to avoid unused imports + +type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'neutral'; + +interface StatusBadgeProps { + status: string; + variant?: BadgeVariant; + type?: 'match' | 'checker' | 'difficulty' | 'custom'; + size?: 'sm' | 'md' | 'lg'; + pulse?: boolean; + className?: string; +} + +const variantClasses: Record = { + default: 'bg-primary/20 text-primary border-primary/30', + success: 'bg-green-500/20 text-green-400 border-green-500/30', + warning: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', + error: 'bg-red-500/20 text-red-400 border-red-500/30', + info: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + neutral: 'bg-gray-500/20 text-gray-400 border-gray-500/30', +}; + +const sizeClasses = { + sm: 'px-1.5 py-0.5 text-xs', + md: 'px-2 py-1 text-xs', + lg: 'px-3 py-1.5 text-sm', +}; + +const getVariantFromMatchStatus = (status: string): BadgeVariant => { + switch (status) { + case 'running': + return 'success'; + case 'lobby': + case 'starting': + return 'info'; + case 'paused': + return 'warning'; + case 'finished': + return 'neutral'; + case 'cancelled': + return 'error'; + default: + return 'default'; + } +}; + +const getVariantFromCheckerResult = (result: string): BadgeVariant => { + switch (result) { + case 'ok': + return 'success'; + case 'corrupt': + case 'mumble': + return 'warning'; + case 'down': + case 'error': + case 'timeout': + return 'error'; + default: + return 'neutral'; + } +}; + +const getVariantFromDifficulty = (difficulty: string): BadgeVariant => { + switch (difficulty) { + case 'beginner': + case 'easy': + return 'success'; + case 'medium': + return 'warning'; + case 'hard': + case 'expert': + return 'error'; + case 'insane': + return 'error'; + default: + return 'default'; + } +}; + +export function StatusBadge({ + status, + variant, + type = 'custom', + size = 'md', + pulse = false, + className, +}: StatusBadgeProps) { + let label = status; + let computedVariant = variant || 'default'; + + if (type === 'match') { + label = MATCH_STATUS_LABELS[status] || status; + if (!variant) { + computedVariant = getVariantFromMatchStatus(status); + } + } else if (type === 'checker') { + label = CHECKER_RESULT_LABELS[status] || status; + if (!variant) { + computedVariant = getVariantFromCheckerResult(status); + } + } else if (type === 'difficulty') { + label = DIFFICULTY_LABELS[status] || status; + if (!variant) { + computedVariant = getVariantFromDifficulty(status); + } + } + + return ( + + {pulse && ( + + + + + )} + {label} + + ); +} + +export default StatusBadge; diff --git a/src/components/common/ThemeToggle.tsx b/src/components/common/ThemeToggle.tsx new file mode 100644 index 0000000..c35b39c --- /dev/null +++ b/src/components/common/ThemeToggle.tsx @@ -0,0 +1,43 @@ +import { Moon, Sun, Monitor } from 'lucide-react'; +import { useUIStore } from '../../store'; +import { cn } from '../../utils/helpers'; + +interface ThemeToggleProps { + className?: string; + showLabel?: boolean; +} + +export function ThemeToggle({ className, showLabel = false }: ThemeToggleProps) { + const { theme, setTheme } = useUIStore(); + + const themes = [ + { value: 'light', icon: Sun, label: 'Светлая' }, + { value: 'dark', icon: Moon, label: 'Тёмная' }, + { value: 'system', icon: Monitor, label: 'Системная' }, + ] as const; + + const cycleTheme = () => { + const currentIndex = themes.findIndex((t) => t.value === theme); + const nextIndex = (currentIndex + 1) % themes.length; + setTheme(themes[nextIndex].value); + }; + + const currentTheme = themes.find((t) => t.value === theme)!; + const Icon = currentTheme.icon; + + return ( + + ); +} + +export default ThemeToggle; diff --git a/src/components/common/index.ts b/src/components/common/index.ts new file mode 100644 index 0000000..cd41798 --- /dev/null +++ b/src/components/common/index.ts @@ -0,0 +1,23 @@ +// Export all common components +export { LoadingSpinner, LoadingOverlay } from './LoadingSpinner'; +export { StatusBadge } from './StatusBadge'; +export { ThemeToggle } from './ThemeToggle'; +export { EmptyState } from './EmptyState'; +export { ErrorBoundary } from './ErrorBoundary'; +export { + Skeleton, + LoadingSkeleton, + CardSkeleton, + TableSkeleton, + TableRowSkeleton, + ListSkeleton, + ProfileSkeleton, + StatsSkeleton, +} from './LoadingSkeleton'; +export { SearchInput } from './SearchInput'; +export { CopyButton } from './CopyButton'; +export { ConfirmDialog, ConfirmProvider, useConfirm } from './ConfirmDialog'; +export { Breadcrumbs } from './Breadcrumbs'; +export { Countdown, InlineCountdown } from './Countdown'; +export { DataTable } from './DataTable'; +export { MarkdownRenderer, MarkdownToText } from './MarkdownRenderer'; diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..d8c7d92 --- /dev/null +++ b/src/components/layout/AppLayout.tsx @@ -0,0 +1,59 @@ +import { Outlet } from 'react-router-dom'; +import { Sidebar } from './Sidebar'; +import { Header } from './Header'; +import { useUIStore } from '../../store'; +import { cn } from '../../utils/helpers'; + +export function AppLayout() { + const { sidebarCollapsed, sidebarMobileOpen, setSidebarMobileOpen } = useUIStore(); + + return ( +
    + {/* Mobile overlay */} + {sidebarMobileOpen && ( +
    setSidebarMobileOpen(false)} + /> + )} + + {/* Sidebar */} +
    + +
    + + {/* Main content */} +
    +
    + +
    + +
    + + {/* Footer */} + +
    +
    + ); +} + +export default AppLayout; diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx new file mode 100644 index 0000000..602c205 --- /dev/null +++ b/src/components/layout/Footer.tsx @@ -0,0 +1,175 @@ +import { Link } from 'react-router-dom'; +import { MessageCircle, Mail, Globe } from 'lucide-react'; +import { cn } from '../../utils/helpers'; + +interface FooterProps { + className?: string; + minimal?: boolean; +} + +export function Footer({ className, minimal = false }: FooterProps) { + const currentYear = new Date().getFullYear(); + + if (minimal) { + return ( +
    +
    +

    © {currentYear} Врата ADA. Все права защищены.

    +
    + Документация + Поддержка + + GitHub + +
    +
    +
    + ); + } + + return ( +
    +
    +
    + {/* Brand */} +
    +

    Врата ADA

    +

    + Платформа для тренировок и обучения CTF в формате Attack-Defence. + Развивайте навыки кибербезопасности вместе с нами. +

    + +
    + + {/* Platform */} +
    +

    Платформа

    +
      +
    • + + Матчи + +
    • +
    • + + Обучение + +
    • +
    • + + Сервисы + +
    • +
    • + + Рейтинг + +
    • +
    • + + Сезоны + +
    • +
    +
    + + {/* Resources */} +
    +

    Ресурсы

    +
      +
    • + + Документация + +
    • +
    • + + API + +
    • +
    • + + Блог + +
    • +
    • + + GitHub + +
    • +
    +
    + + {/* Legal */} +
    +

    Правовая информация

    +
      +
    • + + Условия использования + +
    • +
    • + + Политика конфиденциальности + +
    • +
    • + + Правила платформы + +
    • +
    • + + Поддержка + +
    • +
    +
    +
    + +
    +
    +

    © {currentYear} Врата ADA. Все права защищены.

    +

    + Сделано с ❤️ для CTF-сообщества +

    +
    +
    +
    +
    + ); +} + +export default Footer; diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx new file mode 100644 index 0000000..2d839ae --- /dev/null +++ b/src/components/layout/Header.tsx @@ -0,0 +1,110 @@ +import { Link } from 'react-router-dom'; +import { + Bell, + Search, + Menu, + Timer, +} from 'lucide-react'; +import { cn } from '../../utils/helpers'; +import { useUIStore, useAuthStore, useNotificationStore, useMatchStore } from '../../store'; +import { ThemeToggle } from '../common/ThemeToggle'; +import { formatCountdown } from '../../utils/formatters'; + +export function Header() { + const { setSidebarMobileOpen, sidebarCollapsed, toggleCommandPalette } = useUIStore(); + const { user } = useAuthStore(); + const { unreadCount } = useNotificationStore(); + const { currentMatch, roundTimeRemaining } = useMatchStore(); + + return ( +
    + {/* Left side */} +
    + {/* Mobile menu button */} + + + {/* Search */} + +
    + + {/* Center - Match timer */} + {currentMatch && currentMatch.status === 'running' && ( +
    + +
    +

    + Раунд {currentMatch.currentRound}/{currentMatch.totalRounds} +

    +

    + {formatCountdown(roundTimeRemaining)} +

    +
    + + Смотреть + +
    + )} + + {/* Right side */} +
    + {/* Theme toggle */} + + + {/* Notifications */} + + + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} + + + {/* User menu */} + {user && ( + +
    + {user.displayName?.[0]?.toUpperCase() || user.username[0].toUpperCase()} +
    +
    +

    {user.displayName || user.username}

    +

    + {user.teamId ? 'В команде' : 'Без команды'} +

    +
    + + )} +
    +
    + ); +} + +export default Header; diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx new file mode 100644 index 0000000..e3de726 --- /dev/null +++ b/src/components/layout/MobileNav.tsx @@ -0,0 +1,109 @@ +import { NavLink, useLocation } from 'react-router-dom'; +import { X, LayoutDashboard, Swords, Trophy, Box, Users, GraduationCap, BarChart3, Settings, Shield } from 'lucide-react'; +import { cn } from '../../utils/helpers'; +import { useUIStore, useAuthStore } from '../../store'; + +interface NavItem { + path: string; + label: string; + icon: React.ComponentType<{ className?: string }>; + adminOnly?: boolean; +} + +const navItems: NavItem[] = [ + { path: '/dashboard', label: 'Обзор', icon: LayoutDashboard }, + { path: '/matches', label: 'Матчи', icon: Swords }, + { path: '/scoreboard', label: 'Рейтинг', icon: Trophy }, + { path: '/services', label: 'Сервисы', icon: Box }, + { path: '/teams', label: 'Команды', icon: Users }, + { path: '/training', label: 'Обучение', icon: GraduationCap }, + { path: '/analytics/team', label: 'Аналитика', icon: BarChart3 }, + { path: '/settings', label: 'Настройки', icon: Settings }, + { path: '/admin', label: 'Админ-панель', icon: Shield, adminOnly: true }, +]; + +export function MobileNav() { + const location = useLocation(); + const { sidebarMobileOpen, setSidebarMobileOpen } = useUIStore(); + const { user } = useAuthStore(); + + const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'; + + if (!sidebarMobileOpen) return null; + + return ( + <> + {/* Backdrop */} +
    setSidebarMobileOpen(false)} + /> + + {/* Drawer */} +
    + {/* Header */} +
    +
    +
    + +
    + Врата ADA +
    + +
    + + {/* Navigation */} + + + {/* User info */} + {user && ( +
    +
    +
    + {user.displayName?.[0]?.toUpperCase() || user.username[0].toUpperCase()} +
    +
    +

    {user.displayName || user.username}

    +

    {user.email}

    +
    +
    +
    + )} +
    + + ); +} + +export default MobileNav; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..cff15ec --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -0,0 +1,199 @@ +import { NavLink, useLocation } from 'react-router-dom'; +import { + LayoutDashboard, + Swords, + Trophy, + Box, + Users, + GraduationCap, + BarChart3, + ScrollText, + Settings, + Shield, + ChevronLeft, + ChevronRight, + Calendar, + User, + LogOut, +} from 'lucide-react'; +import { cn } from '../../utils/helpers'; +import { useUIStore, useAuthStore } from '../../store'; + +interface NavItem { + path: string; + label: string; + icon: React.ComponentType<{ className?: string }>; + badge?: number; + adminOnly?: boolean; + organizerOnly?: boolean; +} + +const mainNavItems: NavItem[] = [ + { path: '/dashboard', label: 'Обзор', icon: LayoutDashboard }, + { path: '/matches', label: 'Матчи', icon: Swords }, + { path: '/scoreboard', label: 'Рейтинг', icon: Trophy }, + { path: '/services', label: 'Сервисы', icon: Box }, + { path: '/teams', label: 'Команды', icon: Users }, + { path: '/training', label: 'Обучение', icon: GraduationCap }, + { path: '/seasons', label: 'Сезоны', icon: Calendar }, +]; + +const analyticsNavItems: NavItem[] = [ + { path: '/analytics/team', label: 'Аналитика', icon: BarChart3 }, + { path: '/logs', label: 'Логи', icon: ScrollText }, +]; + +const adminNavItems: NavItem[] = [ + { path: '/admin', label: 'Админ-панель', icon: Shield, adminOnly: true }, +]; + +export function Sidebar() { + const location = useLocation(); + const { sidebarCollapsed, toggleSidebar, setSidebarMobileOpen } = useUIStore(); + const { user, logout } = useAuthStore(); + + const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'; + const isOrganizer = user?.role === 'organizer' || isAdmin; + + const renderNavItem = (item: NavItem) => { + if (item.adminOnly && !isAdmin) return null; + if (item.organizerOnly && !isOrganizer) return null; + + const Icon = item.icon; + const isActive = location.pathname.startsWith(item.path); + + return ( + setSidebarMobileOpen(false)} + className={cn( + 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all', + 'hover:bg-accent hover:text-accent-foreground', + isActive + ? 'bg-primary/10 text-primary' + : 'text-muted-foreground' + )} + > + + {!sidebarCollapsed && ( + <> + {item.label} + {item.badge && item.badge > 0 && ( + + {item.badge} + + )} + + )} + + ); + }; + + return ( + + ); +} + +export default Sidebar; diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts new file mode 100644 index 0000000..eb6937c --- /dev/null +++ b/src/components/layout/index.ts @@ -0,0 +1,6 @@ +// Export all layout components +export { AppLayout } from './AppLayout'; +export { Sidebar } from './Sidebar'; +export { Header } from './Header'; +export { Footer } from './Footer'; +export { MobileNav } from './MobileNav'; diff --git a/src/components/logs/EventTimeline.tsx b/src/components/logs/EventTimeline.tsx new file mode 100644 index 0000000..d9fe86c --- /dev/null +++ b/src/components/logs/EventTimeline.tsx @@ -0,0 +1,75 @@ +import { LogTimelineEvent } from '../../api/types'; +import { cn } from '../../utils/helpers'; +import { formatDateTime } from '../../utils/formatters'; + +interface EventTimelineProps { + events: LogTimelineEvent[]; + className?: string; +} + +const eventColors: Record = { + round_start: 'bg-blue-500', + round_end: 'bg-green-500', + flag_captured: 'bg-red-500', + flag_generated: 'bg-yellow-500', + service_down: 'bg-orange-500', + service_up: 'bg-green-500', + attack_detected: 'bg-purple-500', + score_update: 'bg-blue-500', +}; + +export function EventTimeline({ events, className }: EventTimelineProps) { + return ( +
    + {/* Timeline line */} +
    + + {/* Events */} + {events.map((event, index) => { + const color = eventColors[event.type] || 'bg-muted-foreground'; + + return ( +
    + {/* Dot */} +
    + + {/* Content */} +
    +
    + {event.title} + + {formatDateTime(event.timestamp)} + + {event.importance === 'high' || event.importance === 'critical' && ( + + Важно + + )} +
    +

    {event.description}

    + {event.teams.length > 0 && ( +
    + {event.teams.map((team) => ( + + {team.teamName} + + ))} +
    + )} +
    +
    + ); + })} +
    + ); +} + +export default EventTimeline; diff --git a/src/components/logs/LogEntry.tsx b/src/components/logs/LogEntry.tsx new file mode 100644 index 0000000..132fa11 --- /dev/null +++ b/src/components/logs/LogEntry.tsx @@ -0,0 +1,69 @@ +import { formatDateTime } from '../../utils/formatters'; +import { LOG_LEVEL_COLORS, LOG_LEVEL_LABELS } from '../../utils/constants'; +import { LogEntry as LogEntryType } from '../../api/types'; +import { cn } from '../../utils/helpers'; + +interface LogEntryProps { + log: LogEntryType; + compact?: boolean; + className?: string; +} + +const iconMap: Record = { + match_start: '🚀', + match_end: '🏁', + round_start: '🔄', + round_end: '✅', + flag_captured: '🚩', + flag_generated: '🎯', + service_down: '⚠️', + service_up: '✅', + attack_detected: '⚔️', + score_update: '📊', +}; + +export function LogEntry({ log, compact = false, className }: LogEntryProps) { + const color = LOG_LEVEL_COLORS[log.level]; + const icon = iconMap[log.type] || '📝'; + + return ( +
    + {/* Icon */} + {icon} + + {/* Content */} +
    +
    + + {LOG_LEVEL_LABELS[log.level]} + + + {formatDateTime(log.timestamp)} + + {log.teamName && ( + {log.teamName} + )} +
    +

    {log.message}

    +
    + + {/* Round badge */} + {log.roundNumber && ( + + R{log.roundNumber} + + )} +
    + ); +} + +export default LogEntry; diff --git a/src/components/logs/LogFilter.tsx b/src/components/logs/LogFilter.tsx new file mode 100644 index 0000000..071c81b --- /dev/null +++ b/src/components/logs/LogFilter.tsx @@ -0,0 +1,151 @@ +import { Filter, X } from 'lucide-react'; +import { LogLevel, EventType } from '../../api/types'; +import { cn } from '../../utils/helpers'; +import { LOG_LEVEL_LABELS } from '../../utils/constants'; + +interface LogFilterProps { + selectedLevels?: LogLevel[]; + selectedTypes?: EventType[]; + selectedTeamId?: string | null; + selectedServiceId?: string | null; + search?: string; + onLevelsChange?: (levels: LogLevel[]) => void; + onTypesChange?: (types: EventType[]) => void; + onTeamChange?: (teamId: string | null) => void; + onServiceChange?: (serviceId: string | null) => void; + onSearchChange?: (search: string) => void; + onClear?: () => void; + teams?: { id: string; name: string }[]; + services?: { id: string; name: string }[]; + className?: string; +} + +const logLevels: LogLevel[] = ['debug', 'info', 'warning', 'error', 'critical']; + +export function LogFilter({ + selectedLevels = [], + selectedTypes = [], + selectedTeamId, + selectedServiceId, + search = '', + onLevelsChange, + onTypesChange, + onTeamChange, + onServiceChange, + onSearchChange, + onClear, + teams = [], + services = [], + className, +}: LogFilterProps) { + const hasActiveFilters = + selectedLevels.length > 0 || + selectedTypes.length > 0 || + selectedTeamId || + selectedServiceId || + search; + + return ( +
    + {/* Header */} +
    +
    + + Фильтры логов +
    + {hasActiveFilters && ( + + )} +
    + + {/* Search */} +
    + + onSearchChange?.(e.target.value)} + placeholder="Поиск по тексту..." + className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary" + /> +
    + + {/* Levels */} +
    + +
    + {logLevels.map((level) => { + const isSelected = selectedLevels.includes(level); + const color = LOG_LEVEL_COLORS[level]; + + return ( + + ); + })} +
    +
    + + {/* Teams */} + {teams.length > 0 && ( +
    + + +
    + )} + + {/* Services */} + {services.length > 0 && ( +
    + + +
    + )} +
    + ); +} + +export default LogFilter; diff --git a/src/components/logs/LogStream.tsx b/src/components/logs/LogStream.tsx new file mode 100644 index 0000000..eeab1fe --- /dev/null +++ b/src/components/logs/LogStream.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { LogEntry } from '../../api/types'; +import { LogEntry as LogEntryComponent } from './LogEntry'; +import { cn } from '../../utils/helpers'; + +interface LogStreamProps { + logs: LogEntry[]; + isLoading?: boolean; + isLive?: boolean; + autoScroll?: boolean; + className?: string; + maxHeight?: string; +} + +export function LogStream({ + logs, + isLoading = false, + isLive = false, + autoScroll = true, + className, + maxHeight = '500px', +}: LogStreamProps) { + const containerRef = useRef(null); + const prevLogsLength = useRef(logs.length); + + const scrollToBottom = useCallback(() => { + if (containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, []); + + useEffect(() => { + if (autoScroll && logs.length > prevLogsLength.current) { + scrollToBottom(); + } + prevLogsLength.current = logs.length; + }, [logs.length, autoScroll, scrollToBottom]); + + return ( +
    + {isLoading && logs.length === 0 ? ( +
    + Загрузка логов... +
    + ) : logs.length === 0 ? ( +
    + Нет логов для отображения +
    + ) : ( +
    + {logs.map((log) => ( + + ))} +
    + )} + + {isLive && ( +
    + + + + + Прямой эфир +
    + )} +
    + ); +} + +export default LogStream; diff --git a/src/components/logs/ReplayControls.tsx b/src/components/logs/ReplayControls.tsx new file mode 100644 index 0000000..32cf488 --- /dev/null +++ b/src/components/logs/ReplayControls.tsx @@ -0,0 +1,123 @@ +import { Play, Pause, SkipBack, SkipForward, FastForward, Rewind } from 'lucide-react'; +import { cn } from '../../utils/helpers'; +import { formatCountdown } from '../../utils/formatters'; + +interface ReplayControlsProps { + isPlaying: boolean; + currentTime: number; + duration: number; + speed: number; + onPlay: () => void; + onPause: () => void; + onSeek: (time: number) => void; + onSpeedChange: (speed: number) => void; + onSkipBack?: () => void; + onSkipForward?: () => void; + className?: string; +} + +const speedOptions = [0.25, 0.5, 1, 1.5, 2, 4, 8]; + +export function ReplayControls({ + isPlaying, + currentTime, + duration, + speed, + onPlay, + onPause, + onSeek, + onSpeedChange, + onSkipBack, + onSkipForward, + className, +}: ReplayControlsProps) { + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + + const handleSeek = (e: React.ChangeEvent) => { + const newTime = (parseFloat(e.target.value) / 100) * duration; + onSeek(newTime); + }; + + const cycleSpeed = () => { + const currentIndex = speedOptions.indexOf(speed); + const nextIndex = (currentIndex + 1) % speedOptions.length; + onSpeedChange(speedOptions[nextIndex]); + }; + + return ( +
    + {/* Progress bar */} +
    + + {formatCountdown(currentTime)} + + + + {formatCountdown(duration)} + +
    + + {/* Controls */} +
    + {onSkipBack && ( + + )} + + + + {onSkipForward && ( + + )} + +
    +
    + ); +} + +export default ReplayControls; diff --git a/src/components/logs/index.ts b/src/components/logs/index.ts new file mode 100644 index 0000000..02f289e --- /dev/null +++ b/src/components/logs/index.ts @@ -0,0 +1,6 @@ +// Export all logs components +export { LogStream } from './LogStream'; +export { LogEntry } from './LogEntry'; +export { LogFilter } from './LogFilter'; +export { EventTimeline } from './EventTimeline'; +export { ReplayControls } from './ReplayControls'; diff --git a/src/components/match/FlagSubmitForm.tsx b/src/components/match/FlagSubmitForm.tsx new file mode 100644 index 0000000..5e9d3a7 --- /dev/null +++ b/src/components/match/FlagSubmitForm.tsx @@ -0,0 +1,157 @@ +import { useState, useCallback } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Flag, Loader2, Check, X } from 'lucide-react'; +import { cn } from '../../utils/helpers'; +import toast from 'react-hot-toast'; +import { flagsApi } from '../../api/endpoints'; + +const flagSubmitSchema = z.object({ + flag: z.string().min(1, 'Флаг обязателен'), +}); + +type FlagSubmitFormData = z.infer; + +interface FlagSubmitFormProps { + matchId: string; + onSuccess?: (result: any) => void; + className?: string; + disabled?: boolean; +} + +export function FlagSubmitForm({ + matchId, + onSuccess, + className, + disabled = false, +}: FlagSubmitFormProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [lastResult, setLastResult] = useState<{ + success: boolean; + message: string; + points?: number; + } | null>(null); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(flagSubmitSchema), + defaultValues: { + flag: '', + }, + }); + + const onSubmit = useCallback( + async (data: FlagSubmitFormData) => { + setIsSubmitting(true); + setLastResult(null); + + try { + const result = await flagsApi.submitFlag({ + matchId, + flag: data.flag, + }); + + setLastResult({ + success: result.success, + message: result.message, + points: result.points, + }); + + if (result.success) { + toast.success(`Флаг принят! +${result.points} очков`); + onSuccess?.(result); + reset(); + } else { + toast.error(result.message); + } + } catch (error) { + setLastResult({ + success: false, + message: error instanceof Error ? error.message : 'Ошибка отправки', + }); + toast.error('Не удалось отправить флаг'); + } finally { + setIsSubmitting(false); + } + }, + [matchId, onSuccess, reset] + ); + + return ( +
    + {/* Flag input */} +
    + +
    + + +
    + {errors.flag && ( +

    {errors.flag.message}

    + )} +
    + + {/* Last result */} + {lastResult && ( +
    + {lastResult.success ? ( + + ) : ( + + )} + {lastResult.message} + {lastResult.points && ( + +{lastResult.points} + )} +
    + )} + + {/* Submit button */} + +
    + ); +} + +export default FlagSubmitForm; diff --git a/src/components/match/MatchCard.tsx b/src/components/match/MatchCard.tsx new file mode 100644 index 0000000..09768f8 --- /dev/null +++ b/src/components/match/MatchCard.tsx @@ -0,0 +1,109 @@ +import { Link } from 'react-router-dom'; +import { Swords, Calendar, Users, Clock } from 'lucide-react'; +import { Match } from '../../api/types'; +import { cn } from '../../utils/helpers'; +import { formatDateTime, formatRelativeTime } from '../../utils/formatters'; +import { StatusBadge } from '../common/StatusBadge'; + +interface MatchCardProps { + match: Match; + className?: string; + showDescription?: boolean; +} + +const modeLabels: Record = { + training: 'Тренировка', + tournament: 'Турнир', + scrim: 'Скрим', + practice: 'Практика', +}; + +const modeColors: Record = { + training: 'text-blue-400', + tournament: 'text-yellow-400', + scrim: 'text-purple-400', + practice: 'text-green-400', +}; + +export function MatchCard({ match, className, showDescription = true }: MatchCardProps) { + const isRunning = match.status === 'running'; + const isStarting = match.status === 'starting' || match.status === 'lobby'; + + return ( + + {/* Header */} +
    +
    +
    + + {modeLabels[match.mode]} + + {match.isRanked && ( + + Рейтинг + + )} +
    +

    + {match.title} +

    +
    + +
    + + {/* Description */} + {showDescription && match.description && ( +

    + {match.description} +

    + )} + + {/* Info grid */} +
    +
    + + {match.config.maxTeams} команд +
    +
    + + {match.totalRounds} раундов +
    +
    + + {Math.floor(match.roundDuration / 60)} мин раунд +
    +
    + + {match.scheduledAt ? formatDateTime(match.scheduledAt) : 'TBD'} +
    +
    + + {/* Footer */} +
    +
    + {match.startedAt ? ( + <>Начался {formatRelativeTime(match.startedAt)} + ) : match.scheduledAt ? ( + <>Начнётся {formatRelativeTime(match.scheduledAt)} + ) : ( + <>Создан {formatRelativeTime(match.createdAt)} + )} +
    + {(isRunning || isStarting) && ( +
    + {isRunning ? 'Смотреть' : 'Войти'} + +
    + )} +
    + + ); +} + +export default MatchCard; diff --git a/src/components/match/MatchConfigForm.tsx b/src/components/match/MatchConfigForm.tsx new file mode 100644 index 0000000..59845d7 --- /dev/null +++ b/src/components/match/MatchConfigForm.tsx @@ -0,0 +1,329 @@ +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Plus, Loader2 } from 'lucide-react'; +import { cn } from '../../utils/helpers'; +import { Service } from '../../api/types'; + +const matchConfigSchema = z.object({ + title: z.string().min(3, 'Минимум 3 символа').max(100, 'Максимум 100 символов'), + description: z.string().max(1000, 'Максимум 1000 символов').optional(), + mode: z.enum(['training', 'tournament', 'scrim', 'practice']), + maxTeams: z.number().int().min(2).max(100), + minTeams: z.number().int().min(2), + roundDuration: z.number().int().min(30).max(3600), + totalRounds: z.number().int().min(1).max(500), + flagLifetime: z.number().int().min(10).max(600), + scoringPolicy: z.enum(['classic', 'linear', 'exponential', 'dynamic']), + serviceIds: z.array(z.string()).min(1, 'Выберите хотя бы один сервис'), + scheduledAt: z.string().optional(), + visibility: z.enum(['public', 'private', 'unlisted']), + isRanked: z.boolean(), +}); + +type MatchConfigFormData = z.infer; + +interface MatchConfigFormProps { + services: Service[]; + onSubmit: (data: MatchConfigFormData) => Promise; + isLoading?: boolean; + className?: string; +} + +const modeLabels: Record = { + training: 'Тренировка', + tournament: 'Турнир', + scrim: 'Скрим', + practice: 'Практика', +}; + +const scoringLabels: Record = { + classic: 'Классическое', + linear: 'Линейное', + exponential: 'Экспоненциальное', + dynamic: 'Динамическое', +}; + +const visibilityLabels: Record = { + public: 'Публичный', + private: 'Приватный', + unlisted: 'Скрытый', +}; + +export function MatchConfigForm({ + services, + onSubmit, + isLoading = false, + className, +}: MatchConfigFormProps) { + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(matchConfigSchema), + defaultValues: { + title: '', + description: '', + mode: 'training', + maxTeams: 10, + minTeams: 2, + roundDuration: 300, + totalRounds: 20, + flagLifetime: 120, + scoringPolicy: 'classic', + serviceIds: [], + visibility: 'public', + isRanked: false, + }, + }); + + const selectedServiceIds = watch('serviceIds'); + + const toggleService = (serviceId: string) => { + const current = selectedServiceIds || []; + if (current.includes(serviceId)) { + setValue( + 'serviceIds', + current.filter((id) => id !== serviceId) + ); + } else { + setValue('serviceIds', [...current, serviceId]); + } + }; + + const handleSubmitForm = async (data: any) => { + try { + await onSubmit(data as MatchConfigFormData); + } catch (error) { + // Error handled by parent + } + }; + + return ( +
    + {/* Basic info */} +
    +
    + + + {errors.title && ( +

    {errors.title.message}

    + )} +
    + +
    + +