From eee00c2772f860c697039787c1e0a6218c37abc9 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Fri, 26 Jun 2026 23:32:39 +0900 Subject: [PATCH] feat(panel): add three-sided web console (User, Admin, SysAdmin) Vite + TypeScript + Tailwind single-page console presenting the three operator tiers and consuming the external felis-api face. Build output and design notes are not tracked. --- panel/.gitignore | 15 + panel/index.html | 12 + panel/package-lock.json | 4150 +++++++++++++++++++ panel/package.json | 42 + panel/postcss.config.js | 6 + panel/public/config.json | 4 + panel/src/App.tsx | 52 + panel/src/components/AppShell.tsx | 68 + panel/src/components/CreateServerDialog.tsx | 239 ++ panel/src/components/LogConsole.tsx | 135 + panel/src/components/PhaseBadge.test.ts | 49 + panel/src/components/PhaseBadge.tsx | 58 + panel/src/components/RequireAdmin.tsx | 16 + panel/src/components/ServerCard.tsx | 117 + panel/src/components/States.tsx | 91 + panel/src/components/VoxelFleet.tsx | 168 + panel/src/components/ui/badge.tsx | 28 + panel/src/components/ui/button.tsx | 49 + panel/src/components/ui/card.tsx | 53 + panel/src/components/ui/dialog.tsx | 98 + panel/src/components/ui/input.tsx | 18 + panel/src/components/ui/label.tsx | 18 + panel/src/components/ui/select.tsx | 80 + panel/src/index.css | 58 + panel/src/lib/api.test.ts | 65 + panel/src/lib/api.ts | 129 + panel/src/lib/config.ts | 40 + panel/src/lib/hooks.ts | 67 + panel/src/lib/logstream.test.ts | 184 + panel/src/lib/logstream.ts | 221 + panel/src/lib/nav.test.ts | 39 + panel/src/lib/nav.ts | 81 + panel/src/lib/tier.tsx | 77 + panel/src/lib/types.ts | 100 + panel/src/lib/useLogStream.ts | 53 + panel/src/lib/utils.ts | 7 + panel/src/main.tsx | 10 + panel/src/pages/Account.tsx | 196 + panel/src/pages/Dashboard.tsx | 126 + panel/src/pages/MyServers.tsx | 52 + panel/src/pages/ServerConsole.tsx | 147 + panel/src/pages/admin/AdminHome.tsx | 66 + panel/src/pages/admin/ImageAdmin.tsx | 67 + panel/src/pages/admin/ServerAdmin.tsx | 64 + panel/src/pages/ops/FleetTable.tsx | 27 + panel/src/pages/ops/OpsOverview.tsx | 57 + panel/src/vite-env.d.ts | 10 + panel/tailwind.config.js | 52 + panel/tsconfig.app.json | 26 + panel/tsconfig.json | 4 + panel/tsconfig.node.json | 18 + panel/vite.config.ts | 17 + 52 files changed, 7626 insertions(+) create mode 100644 panel/.gitignore create mode 100644 panel/index.html create mode 100644 panel/package-lock.json create mode 100644 panel/package.json create mode 100644 panel/postcss.config.js create mode 100644 panel/public/config.json create mode 100644 panel/src/App.tsx create mode 100644 panel/src/components/AppShell.tsx create mode 100644 panel/src/components/CreateServerDialog.tsx create mode 100644 panel/src/components/LogConsole.tsx create mode 100644 panel/src/components/PhaseBadge.test.ts create mode 100644 panel/src/components/PhaseBadge.tsx create mode 100644 panel/src/components/RequireAdmin.tsx create mode 100644 panel/src/components/ServerCard.tsx create mode 100644 panel/src/components/States.tsx create mode 100644 panel/src/components/VoxelFleet.tsx create mode 100644 panel/src/components/ui/badge.tsx create mode 100644 panel/src/components/ui/button.tsx create mode 100644 panel/src/components/ui/card.tsx create mode 100644 panel/src/components/ui/dialog.tsx create mode 100644 panel/src/components/ui/input.tsx create mode 100644 panel/src/components/ui/label.tsx create mode 100644 panel/src/components/ui/select.tsx create mode 100644 panel/src/index.css create mode 100644 panel/src/lib/api.test.ts create mode 100644 panel/src/lib/api.ts create mode 100644 panel/src/lib/config.ts create mode 100644 panel/src/lib/hooks.ts create mode 100644 panel/src/lib/logstream.test.ts create mode 100644 panel/src/lib/logstream.ts create mode 100644 panel/src/lib/nav.test.ts create mode 100644 panel/src/lib/nav.ts create mode 100644 panel/src/lib/tier.tsx create mode 100644 panel/src/lib/types.ts create mode 100644 panel/src/lib/useLogStream.ts create mode 100644 panel/src/lib/utils.ts create mode 100644 panel/src/main.tsx create mode 100644 panel/src/pages/Account.tsx create mode 100644 panel/src/pages/Dashboard.tsx create mode 100644 panel/src/pages/MyServers.tsx create mode 100644 panel/src/pages/ServerConsole.tsx create mode 100644 panel/src/pages/admin/AdminHome.tsx create mode 100644 panel/src/pages/admin/ImageAdmin.tsx create mode 100644 panel/src/pages/admin/ServerAdmin.tsx create mode 100644 panel/src/pages/ops/FleetTable.tsx create mode 100644 panel/src/pages/ops/OpsOverview.tsx create mode 100644 panel/src/vite-env.d.ts create mode 100644 panel/tailwind.config.js create mode 100644 panel/tsconfig.app.json create mode 100644 panel/tsconfig.json create mode 100644 panel/tsconfig.node.json create mode 100644 panel/vite.config.ts diff --git a/panel/.gitignore b/panel/.gitignore new file mode 100644 index 0000000..200d433 --- /dev/null +++ b/panel/.gitignore @@ -0,0 +1,15 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# TypeScript incremental build info +*.tsbuildinfo + +# Local env overrides +.env.local +.env.*.local + +# Logs +npm-debug.log* diff --git a/panel/index.html b/panel/index.html new file mode 100644 index 0000000..02119e7 --- /dev/null +++ b/panel/index.html @@ -0,0 +1,12 @@ + + + + + + Felis · Control Panel + + +
+ + + diff --git a/panel/package-lock.json b/panel/package-lock.json new file mode 100644 index 0000000..f9bd340 --- /dev/null +++ b/panel/package-lock.json @@ -0,0 +1,4150 @@ +{ + "name": "felis-panel", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "felis-panel", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-slot": "^1.1.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.2", + "tailwind-merge": "^2.6.0", + "three": "^0.172.0" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@types/three": "^0.172.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.1", + "tailwindcss": "^3.4.17", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.3", + "vite": "^6.0.7", + "vitest": "^4.1.9" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "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.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "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.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "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/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz", + "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.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-collection": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz", + "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0" + }, + "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.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "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.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "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.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", + "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "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.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "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.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", + "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.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-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "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.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", + "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.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-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "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.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.10.tgz", + "integrity": "sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.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-popper": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", + "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.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-portal": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", + "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-layout-effect": "1.1.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-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.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-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "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.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.1.tgz", + "integrity": "sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "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-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "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-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "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.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "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.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "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-escape-keydown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "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.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "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.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "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.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.2" + }, + "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.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "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.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.6.tgz", + "integrity": "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.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/rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "license": "MIT" + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "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/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "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.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.172.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.172.0.tgz", + "integrity": "sha512-LrUtP3FEG26Zg5WiF0nbg8VoXiKokBLTcqM2iLvM9vzcfEiYmmBAPGdBgV0OYx9fvWlY3R/3ERTZcD9X5sc0NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", + "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.4", + "caniuse-lite": "^1.0.30001799", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "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.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "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/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/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/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/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/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.378", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", + "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/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/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", + "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/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "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/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "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": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/three": { + "version": "0.172.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.172.0.tgz", + "integrity": "sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/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.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "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/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/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/panel/package.json b/panel/package.json new file mode 100644 index 0000000..69975d7 --- /dev/null +++ b/panel/package.json @@ -0,0 +1,42 @@ +{ + "name": "felis-panel", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Felis control panel — external face of felis-api (spec §7)", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-slot": "^1.1.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.2", + "tailwind-merge": "^2.6.0", + "three": "^0.172.0" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@types/three": "^0.172.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.1", + "tailwindcss": "^3.4.17", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.3", + "vite": "^6.0.7", + "vitest": "^4.1.9" + } +} diff --git a/panel/postcss.config.js b/panel/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/panel/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/panel/public/config.json b/panel/public/config.json new file mode 100644 index 0000000..f10bbeb --- /dev/null +++ b/panel/public/config.json @@ -0,0 +1,4 @@ +{ + "apiBase": "/api/v1", + "rootDomain": "example.net" +} diff --git a/panel/src/App.tsx b/panel/src/App.tsx new file mode 100644 index 0000000..401e4aa --- /dev/null +++ b/panel/src/App.tsx @@ -0,0 +1,52 @@ +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { TierProvider } from "@/lib/tier"; +import { AppShell } from "@/components/AppShell"; +import { RequireAdmin } from "@/components/RequireAdmin"; +import { Dashboard } from "@/pages/Dashboard"; +import { MyServers } from "@/pages/MyServers"; +import { ServerConsole } from "@/pages/ServerConsole"; +import { Account } from "@/pages/Account"; +import { AdminHome } from "@/pages/admin/AdminHome"; +import { ServerAdmin } from "@/pages/admin/ServerAdmin"; +import { ImageAdmin } from "@/pages/admin/ImageAdmin"; +import { OpsOverview } from "@/pages/ops/OpsOverview"; +import { FleetTable } from "@/pages/ops/FleetTable"; + +// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES): +// / User-Side — app-tier, every authenticated principal +// /admin/* Admin-Side — admin-tier, server & content administration +// /ops/* SysAdmin-Side— admin-tier, platform observability cockpit +// Both admin groups sit behind the SAME RequireAdmin wrapper (one is_admin gate); +// the split is by concern, not by tier. TierProvider fetches /me once at boot. +export default function App() { + return ( + + + + }> + {/* User-Side — app-tier */} + } /> + } /> + } /> + } /> + + {/* Admin-Side — admin-tier (server & content ops) */} + }> + } /> + } /> + } /> + + + {/* SysAdmin-Side — admin-tier (platform observability) */} + }> + } /> + } /> + + + } /> + + + + + ); +} diff --git a/panel/src/components/AppShell.tsx b/panel/src/components/AppShell.tsx new file mode 100644 index 0000000..83c5a1c --- /dev/null +++ b/panel/src/components/AppShell.tsx @@ -0,0 +1,68 @@ +import { NavLink, Outlet } from "react-router-dom"; +import { Cat } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useTier } from "@/lib/tier"; +import { visibleSections } from "@/lib/nav"; + +export function AppShell() { + const { isAdmin } = useTier(); + // Sections are derived purely from is_admin: User-Side always, Admin/SysAdmin + // only for admins. isAdmin is fail-closed (false while /me loads or on failure), + // so admin sections appear only once identity is confirmed. + const sections = visibleSections(isAdmin); + + return ( +
+ + +
+
+ + Felis +
+
+ +
+
+
+ ); +} diff --git a/panel/src/components/CreateServerDialog.tsx b/panel/src/components/CreateServerDialog.tsx new file mode 100644 index 0000000..175a99e --- /dev/null +++ b/panel/src/components/CreateServerDialog.tsx @@ -0,0 +1,239 @@ +import { useState } from "react"; +import { Plus } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api, humanizeError } from "@/lib/api"; +import { useAsync } from "@/lib/hooks"; +import { hostFor, type RuntimeConfig } from "@/lib/config"; +import type { AutostartPolicy, CreateServerRequest } from "@/lib/types"; + +// CreateServerDialog IS the §15 收口 ("structured form, not free YAML"): every +// dangerous knob (hostNetwork, hostPath, arbitrary image, raw resource quantities) +// is simply absent from the UI. The image is chosen from the server-side whitelist +// (GET /images); memory/storage are constrained text the API re-validates. Nothing +// here can express a privileged pod — the form's value space IS the allowlist. + +const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"]; +const STORAGE_OPTIONS = ["5Gi", "10Gi", "20Gi", "50Gi"]; +const POLICIES: { value: AutostartPolicy; label: string }[] = [ + { value: "ownerOnly", label: "Owner only — wake from the panel" }, + { value: "public", label: "Public — any player join wakes it" }, + { value: "allowlist", label: "Allowlist — listed players wake it" }, +]; + +const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/; + +interface Props { + cfg: RuntimeConfig; + onCreated: () => void; +} + +export function CreateServerDialog({ cfg, onCreated }: Props) { + const [open, setOpen] = useState(false); + const images = useAsync(() => api.listImages(), []); + + const [form, setForm] = useState({ + name: "", + subdomain: "", + displayName: "", + image: "", + memory: "4Gi", + storage: "10Gi", + autostartPolicy: "ownerOnly", + }); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + function set(k: K, v: CreateServerRequest[K]) { + setForm((f) => ({ ...f, [k]: v })); + } + + const enabledImages = (images.data ?? []).filter((i) => i.enabled); + const subdomainValid = SUBDOMAIN_RE.test(form.subdomain); + const canSubmit = + !!form.name && + subdomainValid && + !!form.image && + !!form.memory && + !!form.storage && + !submitting; + + async function submit() { + setSubmitting(true); + setError(null); + try { + const payload: CreateServerRequest = { + ...form, + displayName: form.displayName?.trim() || undefined, + }; + await api.createServer(payload); + setOpen(false); + onCreated(); + setForm((f) => ({ ...f, name: "", subdomain: "", displayName: "" })); + } catch (e) { + setError(humanizeError(e)); + } finally { + setSubmitting(false); + } + } + + return ( + + + + + + + Create a server + + Pick from whitelisted images and sizes — the platform provisions the + rest. No raw cluster config is exposed here. + + + +
+
+ + set("name", e.target.value)} + /> +
+ +
+ + set("subdomain", e.target.value.toLowerCase())} + /> +

+ {form.subdomain && !subdomainValid + ? "Lowercase letters, digits and hyphens only." + : `Will be reachable at ${hostFor(form.subdomain || "name", cfg)}`} +

+
+ +
+ + set("displayName", e.target.value)} + /> +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + {error &&

{error}

} +
+ + + + + +
+
+ ); +} diff --git a/panel/src/components/LogConsole.tsx b/panel/src/components/LogConsole.tsx new file mode 100644 index 0000000..738284f --- /dev/null +++ b/panel/src/components/LogConsole.tsx @@ -0,0 +1,135 @@ +import { useCallback, useLayoutEffect, useRef, useState } from "react"; +import { ArrowDown, RotateCw, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useLogStream } from "@/lib/useLogStream"; +import type { LogLevel, StreamStatus } from "@/lib/logstream"; + +// Per-level tint. Plain/info are the default readable foreground; warn/error draw +// the eye. Debug is dimmed so it recedes. The console body is a fixed dark +// surface so it reads as a terminal regardless of the page theme. +const LEVEL_CLASS: Record = { + error: "text-red-400", + warn: "text-amber-300", + info: "text-zinc-200", + debug: "text-zinc-500", + plain: "text-zinc-300", +}; + +const PIN_THRESHOLD_PX = 24; + +function StatusIndicator({ status }: { status: StreamStatus }) { + const map: Record = { + connecting: { dot: "bg-amber-400 animate-pulse", label: "Connecting…", text: "text-amber-300" }, + open: { dot: "bg-emerald-400", label: "Live", text: "text-emerald-300" }, + reconnecting: { dot: "bg-amber-400 animate-pulse", label: "Reconnecting…", text: "text-amber-300" }, + ended: { dot: "bg-zinc-500", label: "Stream ended", text: "text-zinc-400" }, + }; + const s = map[status]; + return ( + + + {s.label} + + ); +} + +/** + * LogConsole renders the §8 read-side live console: a one-way SSE feed of the + * running pod's log. It is intentionally NOT a terminal emulator (xterm) — the + * feed is read-only, so a purpose-built log viewport is the right tool. Writes + * go through the separate RCON command path; the panel never holds an RCON + * password (spec §8/§286). + * + * UX follows modern log viewers: it follows the tail, but if you scroll up to + * read history it stops yanking you down and offers a "Jump to latest" pill; + * scrolling back to the bottom re-pins. The buffer is bounded by the controller. + */ +export function LogConsole({ url }: { url: string }) { + const { lines, status, clear, reconnect } = useLogStream(url); + const scrollRef = useRef(null); + const [pinned, setPinned] = useState(true); + + const onScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const distance = el.scrollHeight - el.scrollTop - el.clientHeight; + setPinned(distance <= PIN_THRESHOLD_PX); + }, []); + + // Follow the tail only while pinned. Runs after layout so the scroll lands on + // the just-appended line; when the user has scrolled up (pinned=false) it does + // nothing, leaving their position untouched. + useLayoutEffect(() => { + if (!pinned) return; + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [lines, pinned]); + + const jumpToLatest = useCallback(() => setPinned(true), []); + + return ( +
+ {/* Toolbar */} +
+ +
+ {status === "ended" && ( + + )} + +
+
+ + {/* Viewport */} +
+
+ {lines.length === 0 ? ( +

+ {status === "ended" + ? "Stream ended — reconnect to resume following the log." + : "Waiting for output…"} +

+ ) : ( + lines.map((line) => ( +
+ {line.text || " "} +
+ )) + )} +
+ + {/* Jump-to-latest pill, shown only when the user has scrolled away from + the tail. */} + {!pinned && ( + + )} +
+
+ ); +} diff --git a/panel/src/components/PhaseBadge.test.ts b/panel/src/components/PhaseBadge.test.ts new file mode 100644 index 0000000..81c2d42 --- /dev/null +++ b/panel/src/components/PhaseBadge.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { PHASE_COLOR, phaseColor, phaseVariant } from "@/components/PhaseBadge"; +import type { Phase } from "@/lib/types"; + +// The closed lifecycle set felis-api can emit — the Go source of truth is +// internal/apis/felis/v1alpha1 MinecraftServerPhase (Unknown/Stopped/Starting/ +// Running/Stopping/Failed), passed through verbatim by the status projection +// (internal/api/k8scluster.go). `satisfies readonly Phase[]` is the real guard: +// it fails the build if any of these strings drops out of the TS Phase union, the +// exact desync that left a "Stopping" server rendering with no variant/colour. +// tsc enforces map↔union agreement, but ONLY this pins union↔backend agreement. +const BACKEND_PHASES = [ + "Unknown", + "Stopped", + "Starting", + "Running", + "Stopping", + "Failed", +] as const satisfies readonly Phase[]; + +describe("phase palette parity", () => { + it("resolves every backend phase to a concrete colour and variant", () => { + for (const phase of BACKEND_PHASES) { + expect(phaseColor(phase), `colour for ${phase}`).toMatch(/^#[0-9a-f]{6}$/i); + expect(phaseVariant(phase), `variant for ${phase}`).toBeTruthy(); + } + }); + + it("gives every phase a distinct hue so transitions are legible", () => { + const hues = BACKEND_PHASES.map((p) => phaseColor(p)); + expect(new Set(hues).size).toBe(BACKEND_PHASES.length); + }); +}); + +describe("unmodelled phase fallback", () => { + // `phase` crosses an unvalidated JSON boundary (api.status() is parsed, never + // schema-checked), so the backend emitting a phase the panel predates must NOT + // produce an undefined variant/colour — it degrades to the neutral Unknown + // styling. Casting simulates that out-of-union runtime value. + const future = "Hibernating" as Phase; + + it("degrades an out-of-union phase to the Unknown colour", () => { + expect(phaseColor(future)).toBe(PHASE_COLOR.Unknown); + }); + + it("degrades an out-of-union phase to the Unknown variant", () => { + expect(phaseVariant(future)).toBe(phaseVariant("Unknown")); + }); +}); diff --git a/panel/src/components/PhaseBadge.tsx b/panel/src/components/PhaseBadge.tsx new file mode 100644 index 0000000..62f2efc --- /dev/null +++ b/panel/src/components/PhaseBadge.tsx @@ -0,0 +1,58 @@ +import { Badge } from "@/components/ui/badge"; +import type { Phase } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +export type BadgeVariant = "default" | "muted" | "destructive" | "outline"; + +/** PHASE_COLOR maps a lifecycle Phase to a foreground hue. Kept in sync with the + * voxel palette (VoxelFleet reads it through phaseColor) so the 3D scene and the + * list read identically. "Stopping" is orange — a teardown sibling to Starting's + * amber, distinct from Stopped's slate so the transient state is legible. */ +export const PHASE_COLOR: Record = { + Running: "#22c55e", + Starting: "#eab308", + Stopping: "#f97316", + Stopped: "#64748b", + Failed: "#ef4444", + Unknown: "#94a3b8", +}; + +const VARIANT: Record = { + Running: "default", + Starting: "outline", + Stopping: "outline", + Stopped: "muted", + Failed: "destructive", + Unknown: "muted", +}; + +// Phases that should pulse: the two in-flight transitions a pod is moving through. +const TRANSIENT: ReadonlySet = new Set(["Starting", "Stopping"]); + +/** phaseColor / phaseVariant resolve a Phase to its hue / Badge variant, degrading + * to the Unknown styling for any value outside the modelled union. `phase` arrives + * from felis-api as parsed-but-unvalidated JSON, so a raw map index can be + * undefined when the backend emits a phase the panel hasn't modelled yet; the + * fallback keeps the badge and the voxel scene rendering a sane neutral instead. */ +export function phaseColor(phase: Phase): string { + return PHASE_COLOR[phase] ?? PHASE_COLOR.Unknown; +} + +export function phaseVariant(phase: Phase): BadgeVariant { + return VARIANT[phase] ?? VARIANT.Unknown; +} + +export function PhaseBadge({ phase }: { phase: Phase }) { + return ( + + + {phase} + + ); +} diff --git a/panel/src/components/RequireAdmin.tsx b/panel/src/components/RequireAdmin.tsx new file mode 100644 index 0000000..1eb9ddd --- /dev/null +++ b/panel/src/components/RequireAdmin.tsx @@ -0,0 +1,16 @@ +import { Outlet } from "react-router-dom"; +import { useTier } from "@/lib/tier"; +import { Loading, NotAuthorized } from "@/components/States"; + +// RequireAdmin is the single route wrapper for BOTH /admin/* and /ops/* (the two +// admin-tier concerns). It is belt-and-suspenders: it spares non-admins a wall of +// 403s, but the server enforces the boundary independently on every data call. +// +// While /me is still loading we show a spinner rather than NotAuthorized, so a +// genuine admin is never briefly told "not authorized" on a slow boot. Once +// settled, isAdmin is the fail-closed verdict (false on any /me failure). +export function RequireAdmin() { + const { isAdmin, loading } = useTier(); + if (loading) return ; + return isAdmin ? : ; +} diff --git a/panel/src/components/ServerCard.tsx b/panel/src/components/ServerCard.tsx new file mode 100644 index 0000000..ffa2187 --- /dev/null +++ b/panel/src/components/ServerCard.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { Play, Square, Hand, Users, ExternalLink, Terminal } from "lucide-react"; +import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { PhaseBadge } from "@/components/PhaseBadge"; +import { api, humanizeError } from "@/lib/api"; +import { hostFor, type RuntimeConfig } from "@/lib/config"; +import type { ServerInfo } from "@/lib/types"; + +interface Props { + server: ServerInfo; + cfg: RuntimeConfig; + /** Called after a successful mutation so the parent can refetch. */ + onChanged: () => void; +} + +export function ServerCard({ server, cfg, onChanged }: Props) { + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const host = hostFor(server.subdomain, cfg); + + async function act(kind: "wake" | "stop" | "claim", fn: () => Promise) { + setBusy(kind); + setError(null); + try { + await fn(); + onChanged(); + } catch (e) { + setError(humanizeError(e)); + } finally { + setBusy(null); + } + } + + const running = server.phase === "Running"; + // Starting and Stopping both have (or had) a pod in flight — neither should + // offer "Wake". Grouping them keeps the footer on the Stop side until the + // server has fully settled into Stopped/Failed. + const transitioning = server.phase === "Starting" || server.phase === "Stopping"; + + return ( + + +
+
+ {server.displayName || server.name} +
+ + {host} + + +
+ +
+ + +
+ + {running ? ( + + {server.players ?? 0} + {server.maxPlayers ? ` / ${server.maxPlayers}` : ""} online + + ) : ( + — + )} +
+ {error &&

{error}

} +
+ + + {server.owned && ( + + + + )} + {server.claimable && !server.owned && ( + + )} + {server.owned && + (running || transitioning ? ( + + ) : ( + + ))} + +
+ ); +} diff --git a/panel/src/components/States.tsx b/panel/src/components/States.tsx new file mode 100644 index 0000000..9fe26c7 --- /dev/null +++ b/panel/src/components/States.tsx @@ -0,0 +1,91 @@ +import { Loader2, AlertTriangle, Inbox, ShieldX, Construction } from "lucide-react"; +import { Link } from "react-router-dom"; +import { humanizeError } from "@/lib/api"; + +export function Loading({ label = "Loading…" }: { label?: string }) { + return ( +
+ + {label} +
+ ); +} + +export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) { + return ( +
+ +

{humanizeError(error)}

+ {onRetry && ( + + )} +
+ ); +} + +export function EmptyState({ + title, + hint, + children, +}: { + title: string; + hint?: string; + children?: React.ReactNode; +}) { + return ( +
+ +
+

{title}

+ {hint &&

{hint}

} +
+ {children} +
+ ); +} + +export function NotAuthorized() { + return ( +
+ +
+

Not authorized

+

+ This area is for platform administrators. If you reached it by typing the + URL, your account does not have admin access — and the API would refuse + the request anyway. +

+
+ + Back to your dashboard + +
+ ); +} + +/** + * PendingBackend is an HONEST placeholder for a surface whose backend read does + * not exist yet (DESIGN-WEB-3SIDES non-goal #3: "a page with no real backend + * source is an honest placeholder, never a mock chart presented as live"). It + * names the missing endpoint so the gap is visible, not papered over with fake + * data. + */ +export function PendingBackend({ endpoint, note }: { endpoint: string; note: string }) { + return ( +
+ +
+

Not wired yet

+

{note}

+

+ pending backend: {endpoint} +

+
+
+ ); +} diff --git a/panel/src/components/VoxelFleet.tsx b/panel/src/components/VoxelFleet.tsx new file mode 100644 index 0000000..beec70f --- /dev/null +++ b/panel/src/components/VoxelFleet.tsx @@ -0,0 +1,168 @@ +import { useEffect, useRef } from "react"; +import * as THREE from "three"; +import { phaseColor } from "@/components/PhaseBadge"; +import type { ServerInfo } from "@/lib/types"; + +// VoxelFleet renders the fleet as a grid of voxels, one per server, colored by +// lifecycle phase — the 3D view of the same state machine the list shows (spec +// §22 phases). Design constraints (panel/DESIGN.md §"where 3D earns its place"): +// • exactly ONE WebGLRenderer for the whole app surface; +// • the render loop is paused while the tab is hidden (no background GPU burn); +// • everything is disposed on unmount (no context leak on route changes). +// Raw three.js (not react-three-fiber) keeps the dependency/build surface minimal. + +interface Props { + servers: ServerInfo[]; +} + +const STARTING = "Starting"; + +export function VoxelFleet({ servers }: Props) { + const mountRef = useRef(null); + // Latest servers without re-running the heavy setup effect on every poll. + const serversRef = useRef(servers); + serversRef.current = servers; + + useEffect(() => { + const mount = mountRef.current; + if (!mount) return; + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); + camera.position.set(6, 5.5, 9); + camera.lookAt(0, 0, 0); + + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + mount.appendChild(renderer.domElement); + renderer.domElement.style.display = "block"; + + scene.add(new THREE.AmbientLight(0xffffff, 0.55)); + const key = new THREE.DirectionalLight(0xffffff, 1.1); + key.position.set(5, 8, 6); + scene.add(key); + const fill = new THREE.DirectionalLight(0x6699ff, 0.35); + fill.position.set(-6, 2, -4); + scene.add(fill); + + const group = new THREE.Group(); + scene.add(group); + + const geometry = new THREE.BoxGeometry(0.9, 0.9, 0.9); + // Tracks the live meshes so we can dispose materials and rebuild on change. + let voxels: { mesh: THREE.Mesh; pulsing: boolean; base: number }[] = []; + + function clearVoxels() { + for (const v of voxels) { + group.remove(v.mesh); + (v.mesh.material as THREE.Material).dispose(); + } + voxels = []; + } + + function build(list: ServerInfo[]) { + clearVoxels(); + const n = Math.max(list.length, 1); + const cols = Math.ceil(Math.sqrt(n)); + const rows = Math.ceil(n / cols); + const spacing = 1.25; + const offX = ((cols - 1) * spacing) / 2; + const offZ = ((rows - 1) * spacing) / 2; + + list.forEach((s, i) => { + const color = new THREE.Color(phaseColor(s.phase)); + const material = new THREE.MeshStandardMaterial({ + color, + roughness: 0.45, + metalness: 0.1, + emissive: color.clone().multiplyScalar(s.phase === "Running" ? 0.25 : 0.05), + }); + const mesh = new THREE.Mesh(geometry, material); + const c = i % cols; + const r = Math.floor(i / cols); + mesh.position.set(c * spacing - offX, 0, r * spacing - offZ); + const base = s.phase === STARTING ? 0.5 : 0.9; + mesh.scale.setScalar(base); + group.add(mesh); + voxels.push({ mesh, pulsing: s.phase === STARTING, base }); + }); + } + + build(serversRef.current); + + // Rebuild only when the *shape* (names + phases) of the fleet changes, polled + // cheaply against a signature so we avoid teardown churn on identical data. + let signature = sig(serversRef.current); + const rebuildTimer = window.setInterval(() => { + const next = sig(serversRef.current); + if (next !== signature) { + signature = next; + build(serversRef.current); + } + }, 1000); + + function resize() { + const w = mount!.clientWidth || 1; + const h = mount!.clientHeight || 1; + renderer.setSize(w, h, false); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + } + resize(); + const ro = new ResizeObserver(resize); + ro.observe(mount); + + let raf = 0; + let t = 0; + let running = true; + const tick = () => { + if (!running) return; + raf = requestAnimationFrame(tick); + t += 0.016; + group.rotation.y = t * 0.18; + for (const v of voxels) { + if (v.pulsing) { + const s = v.base + Math.sin(t * 4) * 0.12 + 0.12; + v.mesh.scale.setScalar(s); + } + } + renderer.render(scene, camera); + }; + tick(); + + // Pause the loop while the tab is hidden — no GPU work in the background. + const onVisibility = () => { + if (document.hidden) { + running = false; + cancelAnimationFrame(raf); + } else if (!running) { + running = true; + tick(); + } + }; + document.addEventListener("visibilitychange", onVisibility); + + return () => { + running = false; + cancelAnimationFrame(raf); + window.clearInterval(rebuildTimer); + document.removeEventListener("visibilitychange", onVisibility); + ro.disconnect(); + clearVoxels(); + geometry.dispose(); + renderer.dispose(); + if (renderer.domElement.parentNode === mount) { + mount.removeChild(renderer.domElement); + } + }; + }, []); + + return
; +} + +/** sig is a cheap fingerprint of the fleet's renderable shape (order-independent + * per index): name+phase pairs. Player counts don't change the voxels, so they + * don't trigger a rebuild. */ +function sig(servers: ServerInfo[]): string { + return servers.map((s) => `${s.name}:${s.phase}`).join("|"); +} diff --git a/panel/src/components/ui/badge.tsx b/panel/src/components/ui/badge.tsx new file mode 100644 index 0000000..5988ce2 --- /dev/null +++ b/panel/src/components/ui/badge.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors", + { + variants: { + variant: { + default: "border-transparent bg-primary/15 text-primary", + muted: "border-transparent bg-muted text-muted-foreground", + destructive: "border-transparent bg-destructive/15 text-destructive", + outline: "text-foreground", + }, + }, + defaultVariants: { variant: "default" }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +export function Badge({ className, variant, ...props }: BadgeProps) { + return ; +} + +export { badgeVariants }; diff --git a/panel/src/components/ui/button.tsx b/panel/src/components/ui/button.tsx new file mode 100644 index 0000000..138af8e --- /dev/null +++ b/panel/src/components/ui/button.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", + ghost: "hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-6", + icon: "h-9 w-9", + }, + }, + defaultVariants: { variant: "default", size: "default" }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +export const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { buttonVariants }; diff --git a/panel/src/components/ui/card.tsx b/panel/src/components/ui/card.tsx new file mode 100644 index 0000000..886d396 --- /dev/null +++ b/panel/src/components/ui/card.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +export const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +export const CardTitle = React.forwardRef< + HTMLHeadingElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +export const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardContent.displayName = "CardContent"; + +export const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; diff --git a/panel/src/components/ui/dialog.tsx b/panel/src/components/ui/dialog.tsx new file mode 100644 index 0000000..7937fcd --- /dev/null +++ b/panel/src/components/ui/dialog.tsx @@ -0,0 +1,98 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export const Dialog = DialogPrimitive.Root; +export const DialogTrigger = DialogPrimitive.Trigger; +export const DialogClose = DialogPrimitive.Close; + +export const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +export const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +export function DialogHeader({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export function DialogFooter({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +export const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; diff --git a/panel/src/components/ui/input.tsx b/panel/src/components/ui/input.tsx new file mode 100644 index 0000000..a2855bd --- /dev/null +++ b/panel/src/components/ui/input.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export const Input = React.forwardRef< + HTMLInputElement, + React.InputHTMLAttributes +>(({ className, type, ...props }, ref) => ( + +)); +Input.displayName = "Input"; diff --git a/panel/src/components/ui/label.tsx b/panel/src/components/ui/label.tsx new file mode 100644 index 0000000..463a14e --- /dev/null +++ b/panel/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cn } from "@/lib/utils"; + +export const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; diff --git a/panel/src/components/ui/select.tsx b/panel/src/components/ui/select.tsx new file mode 100644 index 0000000..3265534 --- /dev/null +++ b/panel/src/components/ui/select.tsx @@ -0,0 +1,80 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export const Select = SelectPrimitive.Root; +export const SelectGroup = SelectPrimitive.Group; +export const SelectValue = SelectPrimitive.Value; + +export const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +export const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + {children} + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +export const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; diff --git a/panel/src/index.css b/panel/src/index.css new file mode 100644 index 0000000..8afa4fc --- /dev/null +++ b/panel/src/index.css @@ -0,0 +1,58 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222 47% 11%; + --card: 0 0% 100%; + --card-foreground: 222 47% 11%; + --popover: 0 0% 100%; + --popover-foreground: 222 47% 11%; + --primary: 142 71% 45%; + --primary-foreground: 144 80% 10%; + --muted: 210 40% 96%; + --muted-foreground: 215 16% 47%; + --accent: 210 40% 96%; + --accent-foreground: 222 47% 11%; + --destructive: 0 72% 51%; + --destructive-foreground: 0 0% 100%; + --border: 214 32% 91%; + --input: 214 32% 91%; + --ring: 142 71% 45%; + --radius: 0.6rem; + } + + .dark { + --background: 224 47% 7%; + --foreground: 210 40% 96%; + --card: 222 40% 10%; + --card-foreground: 210 40% 96%; + --popover: 222 44% 9%; + --popover-foreground: 210 40% 96%; + --primary: 142 70% 45%; + --primary-foreground: 144 80% 8%; + --muted: 217 33% 16%; + --muted-foreground: 215 20% 65%; + --accent: 217 33% 18%; + --accent-foreground: 210 40% 96%; + --destructive: 0 63% 45%; + --destructive-foreground: 210 40% 98%; + --border: 217 33% 18%; + --input: 217 33% 20%; + --ring: 142 70% 45%; + } + + * { + border-color: hsl(var(--border)); + } + + body { + margin: 0; + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + } +} diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts new file mode 100644 index 0000000..1e27152 --- /dev/null +++ b/panel/src/lib/api.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Pin the GET /me wire shape. is_admin crosses an untyped fetch().json() boundary +// (request() does `return parsed as T`), so nothing but a test pins the snake_case +// keys to the Go handler. If the handler's JSON ever drifts to camelCase — or the +// client starts transforming keys — `identity.is_admin` becomes undefined, which is +// falsy, and EVERY admin silently renders as a non-admin while typecheck/build stay +// green. This fixture is the canonical mirror of handlers_user.go handleMe. + +vi.mock("./config", () => ({ + loadConfig: async () => ({ apiBase: "", rootDomain: "example.test" }), +})); + +// Imported after the mock so api.ts picks up the mocked loadConfig. +const { api } = await import("./api"); + +function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) { + return vi.fn(async () => ({ + ok: init?.ok ?? true, + status: init?.status ?? 200, + statusText: "OK", + text: async () => JSON.stringify(body), + })) as unknown as typeof fetch; +} + +describe("api.me wire shape", () => { + beforeEach(() => vi.restoreAllMocks()); + afterEach(() => vi.unstubAllGlobals()); + + it("surfaces snake_case is_admin from GET /me verbatim (admin)", async () => { + // EXACTLY the JSON handlers_user.go handleMe emits. + const body = { user_id: "u1", email: "a@b.c", role: "admin", is_admin: true }; + vi.stubGlobal("fetch", fakeFetch(body)); + const id = await api.me(); + expect(id.is_admin).toBe(true); + expect(id.user_id).toBe("u1"); + expect(id.email).toBe("a@b.c"); + expect(id.role).toBe("admin"); + }); + + it("reports is_admin:false for a non-admin identity", async () => { + const body = { user_id: "u2", email: "x@y.z", role: "user", is_admin: false }; + vi.stubGlobal("fetch", fakeFetch(body)); + const id = await api.me(); + expect(id.is_admin).toBe(false); + expect(id.role).toBe("user"); + }); + + it("calls GET on the /me path", async () => { + const fetchSpy = fakeFetch({ + user_id: "u3", + email: "m@n.o", + role: "user", + is_admin: false, + }); + vi.stubGlobal("fetch", fetchSpy); + await api.me(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock + .calls[0]; + expect(String(url)).toBe("/me"); + expect((opts as RequestInit).method).toBe("GET"); + expect((opts as RequestInit).credentials).toBe("include"); + }); +}); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts new file mode 100644 index 0000000..8b92719 --- /dev/null +++ b/panel/src/lib/api.ts @@ -0,0 +1,129 @@ +import type { + ApiError, + CreateServerRequest, + Identity, + LinkResult, + LinkStatus, + ServerInfo, + WhitelistImage, +} from "./types"; +import { loadConfig } from "./config"; + +// Typed client for the felis-api external face (spec §7). Credentials are sent so +// the upstream Zero-Trust / Access cookie rides along; the panel never holds a +// service token, and the RCON password is never requested (spec §8). + +function isApiError(x: unknown): x is { error: { code: string; message: string } } { + return ( + typeof x === "object" && + x !== null && + "error" in x && + typeof (x as { error: unknown }).error === "object" + ); +} + +async function request(method: string, path: string, body?: unknown): Promise { + const { apiBase } = await loadConfig(); + const res = await fetch(`${apiBase}${path}`, { + method, + credentials: "include", + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + + const text = await res.text(); + const parsed: unknown = text ? JSON.parse(text) : null; + + if (!res.ok) { + const err: ApiError = { + status: res.status, + code: isApiError(parsed) ? parsed.error.code : "error", + message: isApiError(parsed) ? parsed.error.message : res.statusText, + }; + throw err; + } + return parsed as T; +} + +export const api = { + // Identity (spec §7 GET /me) — the tier keystone. is_admin is server-computed + // (Principal.IsAdmin); the panel reads it but re-deriving admin-ness is the + // backend's job. Drives nav + route guards only; every admin route 403s on its + // own regardless of what the panel renders. + me: () => request("GET", "/me"), + + myServers: () => + request<{ servers: ServerInfo[] }>("GET", "/me/servers").then((r) => r.servers ?? []), + + status: (name: string) => request("GET", `/servers/${name}/status`), + + wake: (name: string) => + request<{ name: string; desiredState: string }>("POST", `/servers/${name}/wake`), + + stop: (name: string) => + request<{ name: string; desiredState: string }>("POST", `/servers/${name}/stop`), + + claim: (name: string) => + request<{ name: string; claimed: boolean }>("POST", `/servers/${name}/claim`), + + listImages: () => + request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []), + + createServer: (req: CreateServerRequest) => + request<{ name: string; subdomain: string; desiredState: string }>( + "POST", + "/servers", + req, + ), + + // Account linking (spec §10). Both are POST: start reports status from the + // session principal (no body, side-effect-free), verify consumes a code the + // player was shown in-game. The panel can never mint a code — that is the + // internal in-game face — so there is no client method for it. + linkStatus: () => request("POST", "/account/link/start"), + + linkVerify: (code: string) => + request("POST", "/account/link/verify", { code }), +}; + +/** + * consoleStreamURL builds the §8 read-side SSE endpoint for a server. It mirrors + * request()'s `${apiBase}${path}` join, but is a plain string builder rather than + * a fetch: an EventSource is *constructed* from a URL (and carries the Access + * cookie via withCredentials), it is not requested through this module. The name + * is percent-encoded defensively — server names are validated `[a-z0-9-]` + * upstream, but the URL is built from a router param, so encoding keeps a stray + * value from breaking the URL. + */ +export function consoleStreamURL(apiBase: string, name: string): string { + return `${apiBase}/servers/${encodeURIComponent(name)}/console`; +} + +/** humanizeError turns the stable error code into a user-facing line. */ +export function humanizeError(e: unknown): string { + const err = e as Partial; + switch (err.code) { + case "not_linked": + return "Link your Minecraft account before claiming (Account → Link)."; + case "invalid_code": + return "That link code is invalid or expired — run /link again in-game for a fresh code."; + case "already_linked": + return "That Minecraft account is already linked to another user."; + case "quota_exceeded": + return "You have reached your server quota."; + case "already_claimed": + return "Someone else just claimed this server."; + case "image_not_whitelisted": + return "That image is not on the whitelist."; + case "subdomain_taken": + return "That subdomain is already in use."; + case "already_exists": + return "A server with that name already exists."; + case "cooldown": + return "Wake is cooling down — try again shortly."; + default: + if (err.status === 401) return "Your session expired — please sign in again."; + if (err.status === 403) return "You are not allowed to do that."; + return err.message ?? "Something went wrong."; + } +} diff --git a/panel/src/lib/config.ts b/panel/src/lib/config.ts new file mode 100644 index 0000000..9cd584f --- /dev/null +++ b/panel/src/lib/config.ts @@ -0,0 +1,40 @@ +// Runtime config: the panel is domain-agnostic at BUILD time (CI red line — no +// rootDomain literal may appear in source). apiBase + rootDomain are fetched from +// /config.json at boot, so one build serves any deployment (mirrors the Go side's +// --set root_domain portability, spec §16/§27). A dev fallback reads VITE_* env. + +export interface RuntimeConfig { + apiBase: string; + rootDomain: string; +} + +const FALLBACK: RuntimeConfig = { + apiBase: import.meta.env.VITE_API_BASE ?? "/api/v1", + rootDomain: import.meta.env.VITE_ROOT_DOMAIN ?? "localhost", +}; + +let cached: RuntimeConfig | null = null; + +/** loadConfig fetches /config.json once and caches it; failures fall back to env + * so the SPA still mounts (and surfaces the misconfig in the UI). */ +export async function loadConfig(): Promise { + if (cached) return cached; + try { + const res = await fetch("/config.json", { cache: "no-store" }); + if (!res.ok) throw new Error(`config.json ${res.status}`); + const raw = (await res.json()) as Partial; + cached = { + apiBase: raw.apiBase ?? FALLBACK.apiBase, + rootDomain: raw.rootDomain ?? FALLBACK.rootDomain, + }; + } catch { + cached = FALLBACK; + } + return cached; +} + +/** hostFor composes a server's public hostname from its subdomain and the runtime + * rootDomain — never a hardcoded domain (red line). */ +export function hostFor(subdomain: string, cfg: RuntimeConfig): string { + return `${subdomain}.${cfg.rootDomain}`; +} diff --git a/panel/src/lib/hooks.ts b/panel/src/lib/hooks.ts new file mode 100644 index 0000000..4e34374 --- /dev/null +++ b/panel/src/lib/hooks.ts @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { loadConfig, type RuntimeConfig } from "./config"; + +/** useConfig resolves the runtime config (apiBase + rootDomain) once on mount. + * Returns null until loaded; loadConfig itself never rejects (it falls back). */ +export function useConfig(): RuntimeConfig | null { + const [cfg, setCfg] = useState(null); + useEffect(() => { + let alive = true; + loadConfig().then((c) => { + if (alive) setCfg(c); + }); + return () => { + alive = false; + }; + }, []); + return cfg; +} + +export interface AsyncState { + data: T | null; + error: unknown; + loading: boolean; + /** Re-run the async fn (e.g. after a mutation). */ + reload: () => void; +} + +/** useAsync runs an async producer on mount and on demand, guarding against + * setState-after-unmount and out-of-order responses. */ +export function useAsync(fn: () => Promise, deps: unknown[] = []): AsyncState { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const seq = useRef(0); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const run = useCallback(fn, deps); + + const reload = useCallback(() => { + const ticket = ++seq.current; + setLoading(true); + setError(null); + run().then( + (d) => { + if (ticket === seq.current) { + setData(d); + setLoading(false); + } + }, + (e) => { + if (ticket === seq.current) { + setError(e); + setLoading(false); + } + }, + ); + }, [run]); + + useEffect(() => { + reload(); + return () => { + seq.current++; + }; + }, [reload]); + + return { data, error, loading, reload }; +} diff --git a/panel/src/lib/logstream.test.ts b/panel/src/lib/logstream.test.ts new file mode 100644 index 0000000..96ea5e9 --- /dev/null +++ b/panel/src/lib/logstream.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from "vitest"; +import { + LogStreamController, + classifyLogLine, + type EventSourceFactory, + type EventSourceLike, +} from "./logstream"; + +// FakeEventSource drives the controller without a browser: Node has no +// EventSource, and even in jsdom it would dial a real socket. The controller +// takes its EventSource via a factory precisely so this fake can model the +// lifecycle — open, message, transient drop (reconnecting), fatal close (ended), +// and teardown — deterministically. It matches EventSourceLike structurally. +class FakeEventSource implements EventSourceLike { + onopen: ((ev: unknown) => void) | null = null; + onmessage: ((ev: { data: string }) => void) | null = null; + onerror: ((ev: unknown) => void) | null = null; + readyState = 0; // CONNECTING + closed = false; + readonly url: string; + + constructor(url: string) { + this.url = url; + } + + close(): void { + this.closed = true; + this.readyState = 2; // CLOSED + } + + // Test drivers mirroring what the browser would invoke. + emitOpen(): void { + this.readyState = 1; // OPEN + this.onopen?.({}); + } + emitMessage(data: string): void { + this.onmessage?.({ data }); + } + emitError(readyState: number): void { + this.readyState = readyState; + this.onerror?.({}); + } +} + +function makeFactory(): { factory: EventSourceFactory; created: FakeEventSource[] } { + const created: FakeEventSource[] = []; + const factory: EventSourceFactory = (url) => { + const es = new FakeEventSource(url); + created.push(es); + return es; + }; + return { factory, created }; +} + +describe("classifyLogLine", () => { + const cases: Array<[string, ReturnType]> = [ + ["[12:30:01] [Server thread/INFO]: Done (5.123s)!", "info"], + ["[12:30:02] [Server thread/WARN]: Can't keep up!", "warn"], + ["[12:30:03] [Server thread/ERROR]: Encountered an exception", "error"], + ["[12:30:04] [Server thread/SEVERE]: fatal boot failure", "error"], + ["[12:30:05] [Worker/FATAL]: out of memory", "error"], + ["[12:30:06] [Server thread/DEBUG]: tick took 1ms", "debug"], + ["[12:30:07] [Server thread/TRACE]: packet in", "debug"], + ["[12:30:08] [Server thread/WARNING]: deprecated config", "warn"], + ["[INFO] bracketed level form", "info"], + [" at net.minecraft.server.Foo.bar(Foo.java:42)", "plain"], + [" the build had an ERROR earlier lol", "plain"], // chat must not tint + ["plain server line with no marker", "plain"], + ]; + for (const [line, want] of cases) { + it(`classifies ${JSON.stringify(line)} as ${want}`, () => { + expect(classifyLogLine(line)).toBe(want); + }); + } +}); + +describe("LogStreamController", () => { + it("transitions connecting -> open and appends classified lines", () => { + const { factory, created } = makeFactory(); + const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory }); + ctrl.open(); + expect(created).toHaveLength(1); + expect(created[0].url).toBe("/api/v1/servers/s/console"); + expect(ctrl.getSnapshot().status).toBe("connecting"); + + created[0].emitOpen(); + expect(ctrl.getSnapshot().status).toBe("open"); + + created[0].emitMessage("[12:00:00] [Server thread/WARN]: heads up"); + const { lines } = ctrl.getSnapshot(); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatchObject({ seq: 0, level: "warn" }); + expect(lines[0].text).toContain("heads up"); + }); + + it("bounds the ring buffer, dropping the oldest lines", () => { + const { factory, created } = makeFactory(); + const ctrl = new LogStreamController({ url: "/c", factory, maxLines: 2 }); + ctrl.open(); + created[0].emitMessage("line0"); + created[0].emitMessage("line1"); + created[0].emitMessage("line2"); + const { lines } = ctrl.getSnapshot(); + expect(lines).toHaveLength(2); + // Oldest dropped; the survivors keep their monotonic seq (1 then 2). + expect(lines.map((l) => l.text)).toEqual(["line1", "line2"]); + expect(lines.map((l) => l.seq)).toEqual([1, 2]); + }); + + it("returns a stable snapshot reference between mutations", () => { + const { factory, created } = makeFactory(); + const ctrl = new LogStreamController({ url: "/c", factory }); + ctrl.open(); // status already 'connecting' -> no commit, snapshot stable + const s1 = ctrl.getSnapshot(); + expect(ctrl.getSnapshot()).toBe(s1); + created[0].emitMessage("x"); + expect(ctrl.getSnapshot()).not.toBe(s1); + }); + + it("notifies subscribers on mutation and stops after unsubscribe", () => { + const { factory, created } = makeFactory(); + const ctrl = new LogStreamController({ url: "/c", factory }); + ctrl.open(); + let hits = 0; + const unsub = ctrl.subscribe(() => { + hits++; + }); + created[0].emitOpen(); // commit + created[0].emitMessage("a"); // commit + expect(hits).toBe(2); + unsub(); + created[0].emitMessage("b"); + expect(hits).toBe(2); // no further notifications + }); + + it("maps a transient drop to reconnecting and a fatal close to ended", () => { + const { factory, created } = makeFactory(); + const ctrl = new LogStreamController({ url: "/c", factory }); + ctrl.open(); + created[0].emitOpen(); + created[0].emitError(0); // readyState CONNECTING -> auto-retrying + expect(ctrl.getSnapshot().status).toBe("reconnecting"); + created[0].emitError(2); // readyState CLOSED -> fatal (e.g. 409 not_running) + expect(ctrl.getSnapshot().status).toBe("ended"); + }); + + it("ignores events after close (teardown linchpin)", () => { + const { factory, created } = makeFactory(); + const ctrl = new LogStreamController({ url: "/c", factory }); + ctrl.open(); + created[0].emitOpen(); + ctrl.close(); + expect(created[0].closed).toBe(true); + // A late event from the now-detached source must not mutate state. + created[0].emitMessage("ghost"); + created[0].emitError(0); + expect(ctrl.getSnapshot().lines).toHaveLength(0); + expect(ctrl.getSnapshot().status).toBe("open"); + }); + + it("reconnect closes the old stream and dials a fresh one", () => { + const { factory, created } = makeFactory(); + const ctrl = new LogStreamController({ url: "/c", factory }); + ctrl.open(); + created[0].emitError(2); // ended + ctrl.reconnect(); + expect(created).toHaveLength(2); + expect(created[0].closed).toBe(true); + expect(ctrl.getSnapshot().status).toBe("connecting"); + created[1].emitOpen(); + expect(ctrl.getSnapshot().status).toBe("open"); + }); + + it("clear empties the buffer without disturbing status", () => { + const { factory, created } = makeFactory(); + const ctrl = new LogStreamController({ url: "/c", factory }); + ctrl.open(); + created[0].emitOpen(); + created[0].emitMessage("a"); + ctrl.clear(); + expect(ctrl.getSnapshot().lines).toHaveLength(0); + expect(ctrl.getSnapshot().status).toBe("open"); + }); +}); diff --git a/panel/src/lib/logstream.ts b/panel/src/lib/logstream.ts new file mode 100644 index 0000000..b8df2a0 --- /dev/null +++ b/panel/src/lib/logstream.ts @@ -0,0 +1,221 @@ +// Read-side console core (spec §8 读=pods/log follow). The felis-api endpoint +// `GET /servers/{name}/console` relays the running pod's log as Server-Sent +// Events — one `data: ` per log line — authenticated by the same +// Cloudflare Access cookie the rest of api.ts rides on (credentials:"include"). +// A native browser EventSource with { withCredentials: true } therefore attaches +// directly; no Authorization header is needed (EventSource cannot set one). +// +// This module is deliberately framework-agnostic: the EventSource is injected +// (see EventSourceFactory) so the lifecycle — status transitions, the bounded +// ring buffer, reconnect, teardown — is unit-tested under Node against a fake, +// not left to a "tsc + build" green that only proves it compiles. The thin React +// binding lives in useLogStream.ts; the viewport in components/LogConsole.tsx. + +/** LogLevel is the coarse severity used only for tinting a line. */ +export type LogLevel = "error" | "warn" | "info" | "debug" | "plain"; + +/** + * StreamStatus models the EventSource lifecycle as the UI cares about it: + * - connecting: the initial dial (or a manual reconnect) is in flight + * - open: the stream is live and delivering lines + * - reconnecting: a transient drop; native EventSource is auto-retrying + * (readyState returned to CONNECTING after onerror) + * - ended: a fatal close — a non-200 response (e.g. the 409 a stopped + * server returns, or wrong content-type) makes EventSource go + * CLOSED with no auto-retry; the user may reconnect manually + */ +export type StreamStatus = "connecting" | "open" | "reconnecting" | "ended"; + +/** A single rendered log line. seq is a monotonic id for stable React keys. */ +export interface LogLine { + seq: number; + text: string; + level: LogLevel; +} + +/** Immutable snapshot consumed by useSyncExternalStore. */ +export interface LogStreamSnapshot { + lines: LogLine[]; + status: StreamStatus; +} + +// EventSource readyState constants. We avoid referencing the DOM EventSource +// global here so the module loads under Node (tests) where it is undefined. +const ES_CLOSED = 2; + +/** + * EventSourceLike is the minimal slice of the DOM EventSource the controller + * depends on, so a fake can drive it in tests. The handler params are `any` + * rather than a precise event type on purpose: under strictFunctionTypes a real + * browser EventSource (handlers typed `(ev: Event)` / `(ev: MessageEvent)`) is + * only assignable to this shim if the shim's params are at least as wide as the + * DOM's — `any` is bivariant, so a browser EventSource satisfies it structurally + * while a stricter test fake (e.g. `(ev: { data: string })`) still implements + * it. We never inspect the event beyond `.data`, so the looseness costs nothing. + */ +export interface EventSourceLike { + onopen: ((ev: any) => void) | null; + onmessage: ((ev: any) => void) | null; + onerror: ((ev: any) => void) | null; + readyState: number; + close(): void; +} + +/** Builds an EventSource for a URL (production: the browser ctor; tests: a fake). */ +export type EventSourceFactory = (url: string) => EventSourceLike; + +export interface LogStreamOptions { + url: string; + factory: EventSourceFactory; + /** Ring-buffer bound; older lines are dropped past this (default 2000). */ + maxLines?: number; +} + +const DEFAULT_MAX_LINES = 2000; + +/** + * classifyLogLine maps a Minecraft log line to a coarse level for tinting. + * + * Minecraft and its proxies/loaders (vanilla, Paper, Fabric, Forge, NeoForge, + * Velocity) all log via log4j2, whose default pattern carries the level as a + * `thread/LEVEL]` marker, e.g. + * + * [12:30:01] [Server thread/INFO]: Done (5.123s)! For help, type "help" + * [12:30:02] [Server thread/WARN]: Can't keep up! Is the server overloaded? + * [12:30:03] [Server thread/ERROR]: Encountered an unexpected exception + * + * We match that marker (and the bracketed `[LEVEL]` variant) rather than scanning + * the whole line, so a chat message that merely mentions "error" is not tinted + * red. Anything unrecognized — stack-trace continuations, plain output — is + * "plain" and renders in the default colour. + */ +export function classifyLogLine(line: string): LogLevel { + const m = line.match(/[/[](SEVERE|FATAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE)\]/); + if (!m) return "plain"; + switch (m[1]) { + case "SEVERE": + case "FATAL": + case "ERROR": + return "error"; + case "WARNING": + case "WARN": + return "warn"; + case "INFO": + return "info"; + default: + return "debug"; // DEBUG, TRACE + } +} + +/** + * LogStreamController owns one EventSource and exposes a useSyncExternalStore + * surface (subscribe + cached getSnapshot). It is the single place that handles: + * + * - status transitions driven by EventSource events, + * - the bounded ring buffer (memory stays capped under a long live follow), + * - reconnect (manual, after a fatal `ended`), + * - teardown (close()) — the linchpin that the React effect's cleanup calls so + * a StrictMode dev double-mount, or a route change, never leaks a stream. + */ +export class LogStreamController { + private es: EventSourceLike | null = null; + private lines: LogLine[] = []; + private status: StreamStatus = "connecting"; + private seq = 0; + private readonly url: string; + private readonly factory: EventSourceFactory; + private readonly maxLines: number; + private readonly listeners = new Set<() => void>(); + // snapshot is rebuilt only inside commit(), so getSnapshot returns a stable + // reference between mutations — required by useSyncExternalStore to avoid an + // infinite render loop. + private snapshot: LogStreamSnapshot; + + constructor(opts: LogStreamOptions) { + this.url = opts.url; + this.factory = opts.factory; + this.maxLines = opts.maxLines ?? DEFAULT_MAX_LINES; + this.snapshot = { lines: this.lines, status: this.status }; + } + + subscribe = (fn: () => void): (() => void) => { + this.listeners.add(fn); + return () => { + this.listeners.delete(fn); + }; + }; + + getSnapshot = (): LogStreamSnapshot => this.snapshot; + + /** open dials the stream. Call once after construction (the hook's effect). */ + open(): void { + this.connect(); + } + + /** reconnect closes any current stream and dials again (manual retry). */ + reconnect(): void { + this.disconnect(); + this.connect(); + } + + /** clear empties the buffer without touching the stream. */ + clear(): void { + this.lines = []; + this.commit(); + } + + /** close tears the stream down for good. Idempotent. */ + close(): void { + this.disconnect(); + } + + private connect(): void { + this.setStatus("connecting"); + const es = this.factory(this.url); + this.es = es; + es.onopen = () => this.setStatus("open"); + es.onmessage = (ev) => this.pushLine(ev.data); + es.onerror = () => { + // Native EventSource auto-reconnects on a transient drop (readyState + // returns to CONNECTING); only a fatal response — non-200 / wrong + // content-type, e.g. the 409 a stopped server returns — goes CLOSED, with + // no further retry. Distinguish so the UI shows "reconnecting" vs "ended". + if (this.es && this.es.readyState === ES_CLOSED) { + this.setStatus("ended"); + } else { + this.setStatus("reconnecting"); + } + }; + } + + private disconnect(): void { + if (this.es) { + // Detach handlers first so a close()-induced onerror can't flip status. + this.es.onopen = null; + this.es.onmessage = null; + this.es.onerror = null; + this.es.close(); + this.es = null; + } + } + + private pushLine(text: string): void { + const next = this.lines.concat({ seq: this.seq++, text, level: classifyLogLine(text) }); + if (next.length > this.maxLines) { + next.splice(0, next.length - this.maxLines); + } + this.lines = next; + this.commit(); + } + + private setStatus(s: StreamStatus): void { + if (this.status === s) return; + this.status = s; + this.commit(); + } + + private commit(): void { + this.snapshot = { lines: this.lines, status: this.status }; + this.listeners.forEach((fn) => fn()); + } +} diff --git a/panel/src/lib/nav.test.ts b/panel/src/lib/nav.test.ts new file mode 100644 index 0000000..69d3df5 --- /dev/null +++ b/panel/src/lib/nav.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { NAV_SECTIONS, visibleSections } from "./nav"; + +// visibleSections is the load-bearing tier decision: get it wrong and either an +// admin loses their tools or a non-admin sees admin nav. These cases pin the +// fail-closed default (loading/error → false → User-Side only) and the full-admin +// expansion, and guard the invariant that User-Side is never admin-gated. + +describe("visibleSections", () => { + it("shows only the User-Side section to a non-admin", () => { + const ids = visibleSections(false).map((s) => s.id); + expect(ids).toEqual(["user"]); + }); + + it("treats the fail-closed default (false) exactly like a non-admin", () => { + // TierProvider passes `false` while /me is loading or after it rejects. That + // path MUST collapse to User-Side only, never leak Admin/Ops nav. + expect(visibleSections(false)).toHaveLength(1); + expect(visibleSections(false)[0].id).toBe("user"); + }); + + it("shows User-Side, Admin-Side and SysAdmin-Side to an admin", () => { + const ids = visibleSections(true).map((s) => s.id); + expect(ids).toEqual(["user", "admin", "ops"]); + }); + + it("keeps the User-Side section ungated so it survives both branches", () => { + const user = NAV_SECTIONS.find((s) => s.id === "user"); + expect(user?.adminOnly).toBe(false); + expect(visibleSections(true)).toContainEqual(user); + expect(visibleSections(false)).toContainEqual(user); + }); + + it("gates every non-user section behind admin", () => { + for (const s of NAV_SECTIONS) { + if (s.id !== "user") expect(s.adminOnly).toBe(true); + } + }); +}); diff --git a/panel/src/lib/nav.ts b/panel/src/lib/nav.ts new file mode 100644 index 0000000..009487f --- /dev/null +++ b/panel/src/lib/nav.ts @@ -0,0 +1,81 @@ +import { + LayoutDashboard, + Server, + UserRound, + ShieldCheck, + ServerCog, + Boxes, + Gauge, + Network, + type LucideIcon, +} from "lucide-react"; + +// The shell navigation model, kept as plain data so the "which sections does this +// identity see" decision is a pure function (visibleSections) that vitest can pin +// without rendering React. The three sections map onto DESIGN-WEB-3SIDES §2: +// User-Side is always present (app-tier); Admin-Side and SysAdmin-Side are a +// navigational separation of *concern* over the SAME admin tier — both gated by +// the one `is_admin` flag, surfaced as two sections only for admins. +// +// Hiding a section is UX convenience, NOT a security control: every /admin and +// /ops data call is independently 403-gated server-side (the RequireAdmin route +// wrapper is belt-and-suspenders on top of that). + +export interface NavItem { + to: string; + label: string; + icon: LucideIcon; + /** react-router NavLink `end` — exact-match active styling for index routes. */ + end?: boolean; +} + +export interface NavSection { + id: "user" | "admin" | "ops"; + /** Section heading shown in the sidebar; null renders no heading (User-Side). */ + title: string | null; + /** When true the section is shown only to admins (is_admin === true). */ + adminOnly: boolean; + items: NavItem[]; +} + +export const NAV_SECTIONS: NavSection[] = [ + { + id: "user", + title: null, + adminOnly: false, + items: [ + { to: "/", label: "Dashboard", icon: LayoutDashboard, end: true }, + { to: "/servers", label: "My servers", icon: Server }, + { to: "/account", label: "Account", icon: UserRound }, + ], + }, + { + id: "admin", + title: "Admin", + adminOnly: true, + items: [ + { to: "/admin", label: "Overview", icon: ShieldCheck, end: true }, + { to: "/admin/servers", label: "Servers", icon: ServerCog }, + { to: "/admin/images", label: "Images", icon: Boxes }, + ], + }, + { + id: "ops", + title: "SysAdmin", + adminOnly: true, + items: [ + { to: "/ops", label: "Overview", icon: Gauge, end: true }, + { to: "/ops/fleet", label: "Fleet", icon: Network }, + ], + }, +]; + +/** + * visibleSections returns the sections a caller with the given admin flag may see. + * Pure and total: a non-admin (or the fail-closed `false` used while /me is still + * loading or after it errors) gets exactly the User-Side section; an admin gets all + * three. This is the single decision the sidebar renders from. + */ +export function visibleSections(isAdmin: boolean): NavSection[] { + return NAV_SECTIONS.filter((s) => !s.adminOnly || isAdmin); +} diff --git a/panel/src/lib/tier.tsx b/panel/src/lib/tier.tsx new file mode 100644 index 0000000..9bc0d84 --- /dev/null +++ b/panel/src/lib/tier.tsx @@ -0,0 +1,77 @@ +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import type { Identity } from "./types"; +import { api } from "./api"; + +// TierProvider fetches GET /me exactly once at boot and exposes the result through +// context. Two design rules, both load-bearing: +// +// 1. Fail-closed: `isAdmin` is `identity?.is_admin === true`. While /me is in +// flight (`identity === null`) or after it rejects, isAdmin is false — admin +// nav/routes stay hidden. The `=== true` (not just truthy) also closes the +// wire-shape trap: if the JSON ever arrives without `is_admin`, the value is +// `undefined`, which is correctly non-admin rather than a thrown access. +// +// 2. Still-functional on failure: a /me rejection is caught and swallowed to a +// null identity, NOT re-thrown. A logged-in user whose /me momentarily fails +// still gets the full User-Side app; they simply don't see admin surfaces. +// (The backend 403s admin data calls independently, so this is safe.) +// +// This is UX truth, not a security control — see DESIGN-WEB-3SIDES §1. + +export interface TierState { + /** The caller's identity, or null while loading or after a failed /me. */ + identity: Identity | null; + /** True only while the initial /me request is in flight. */ + loading: boolean; + /** Server-computed admin flag; false while loading or on failure (fail-closed). */ + isAdmin: boolean; +} + +const TierContext = createContext({ + identity: null, + loading: true, + isAdmin: false, +}); + +export function TierProvider({ children }: { children: ReactNode }) { + const [identity, setIdentity] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let alive = true; + api + .me() + .then((id) => { + if (alive) setIdentity(id); + }) + .catch(() => { + // Fail-closed but functional: stay non-admin, keep rendering User-Side. + if (alive) setIdentity(null); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, []); + + const isAdmin = identity?.is_admin === true; + + return ( + + {children} + + ); +} + +/** useTier reads the boot-time identity/tier state. */ +export function useTier(): TierState { + return useContext(TierContext); +} diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts new file mode 100644 index 0000000..560de43 --- /dev/null +++ b/panel/src/lib/types.ts @@ -0,0 +1,100 @@ +// Mirror of the felis-api external-face JSON (spec §7). Kept intentionally narrow: +// only the fields the panel renders, so the UI never depends on internal shapes. + +// The full lifecycle set felis-api can emit (v1alpha1 MinecraftServerPhase, passed +// through verbatim by the status projection). "Stopping" is the transient teardown +// state between Running and Stopped; the panel must model it or a stopping server +// renders with no variant/color. Consumers that index a phase→x map MUST still +// fall back for any value outside this union — `phase` crosses an unvalidated JSON +// boundary, so the type is a documentation of intent, not a runtime guarantee. +export type Phase = + | "Stopped" + | "Starting" + | "Running" + | "Stopping" + | "Failed" + | "Unknown"; + +export type AutostartPolicy = "ownerOnly" | "public" | "allowlist"; + +/** ServerInfo is the projection returned by GET /servers/{name}/status and + * GET /me/servers (the latter wraps a list under { servers: [...] }). */ +export interface ServerInfo { + name: string; + subdomain: string; + displayName?: string; + phase: Phase; + desiredState?: "Running" | "Stopped"; + players?: number; + maxPlayers?: number; + autostartPolicy?: AutostartPolicy; + /** Whether the caller may claim this server (unowned + linked + quota). */ + claimable?: boolean; + /** Whether the caller owns it. */ + owned?: boolean; +} + +/** WhitelistImage is one row of GET /images (the create-form dropdown source). */ +export interface WhitelistImage { + image_ref: string; + enabled: boolean; + source?: string; +} + +/** CreateServerRequest is the §15 structured form — the ONLY create path. */ +export interface CreateServerRequest { + name: string; + subdomain: string; + displayName?: string; + image: string; + memory: string; + storage: string; + autostartPolicy?: AutostartPolicy; + resources?: { + cpu?: string; + cpuRequest?: string; + memory?: string; + memoryRequest?: string; + }; +} + +/** LinkStatus projects POST /account/link/start (spec §10): whether the caller's + * session is already bound to a Minecraft identity. The endpoint also returns an + * API-consumer `instructions` string; the panel renders its own player-facing copy + * (the raw string names the REST endpoint), so it is intentionally not modelled. */ +export interface LinkStatus { + linked: boolean; +} + +/** LinkResult projects a successful POST /account/link/verify (spec §10): the + * account_links binding is written and the now-linked Minecraft UUID is echoed + * back (the only place the panel learns the UUID — start never returns it). */ +export interface LinkResult { + linked: boolean; + mc_uuid: string; +} + +/** ApiError is the stable error envelope ({ error: { code, message } }). */ +export interface ApiError { + status: number; + code: string; + message: string; +} + +/** Identity mirrors GET /api/v1/me (app-tier — every authenticated principal may + * read *their own* identity). The keys are snake_case because they mirror the Go + * handler's JSON map verbatim (handlers_user.go handleMe → {user_id, email, role, + * is_admin}); a camelCase rename here would silently read `undefined` across the + * untyped fetch().json() boundary and make every admin look like a non-admin. + * + * `is_admin` is the ONLY field the tier model trusts: it is computed server-side as + * Principal.IsAdmin() (Role=="admin" AND ViaAdminAccess — the graded-ZT rule), so + * the panel never re-derives admin-ness from `role` alone. `role` is carried for + * display only and, like `phase`, is documentation of intent, not a runtime + * guarantee — it crosses an unvalidated JSON boundary. */ +export interface Identity { + user_id: string; + email: string; + role: "user" | "admin"; + is_admin: boolean; +} diff --git a/panel/src/lib/useLogStream.ts b/panel/src/lib/useLogStream.ts new file mode 100644 index 0000000..6fb8ab2 --- /dev/null +++ b/panel/src/lib/useLogStream.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; +import { + LogStreamController, + type EventSourceFactory, + type StreamStatus, + type LogLine, +} from "./logstream"; + +// browserEventSource is the production factory: a native EventSource with +// credentials, so the Cloudflare Access cookie rides along exactly as api.ts's +// fetch(credentials:"include") does. EventSource cannot set an Authorization +// header, so cookie auth is what makes a direct SSE attach viable (spec §8). +const browserEventSource: EventSourceFactory = (url) => + new EventSource(url, { withCredentials: true }); + +export interface UseLogStream { + lines: LogLine[]; + status: StreamStatus; + clear: () => void; + reconnect: () => void; +} + +/** + * useLogStream binds a LogStreamController to React. The controller is the store + * (useSyncExternalStore); this hook only manages its lifecycle: + * + * - One controller per url. A ref keyed on url keeps a single stable instance + * across re-renders (incl. StrictMode's double render) so buffered lines + * survive; it is recreated only when url actually changes. + * - The effect calls open() on mount and close() on cleanup. That cleanup is + * the teardown linchpin: under StrictMode's dev mount→unmount→remount, the + * first EventSource is closed before the second opens, so no stream leaks and + * lines are never doubled. + */ +export function useLogStream(url: string): UseLogStream { + const ref = useRef<{ url: string; ctrl: LogStreamController } | null>(null); + if (ref.current === null || ref.current.url !== url) { + ref.current = { url, ctrl: new LogStreamController({ url, factory: browserEventSource }) }; + } + const controller = ref.current.ctrl; + + useEffect(() => { + controller.open(); + return () => controller.close(); + }, [controller]); + + const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot); + + const clear = useCallback(() => controller.clear(), [controller]); + const reconnect = useCallback(() => controller.reconnect(), [controller]); + + return { lines: snapshot.lines, status: snapshot.status, clear, reconnect }; +} diff --git a/panel/src/lib/utils.ts b/panel/src/lib/utils.ts new file mode 100644 index 0000000..1538941 --- /dev/null +++ b/panel/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** cn merges conditional class names and resolves Tailwind conflicts. */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/panel/src/main.tsx b/panel/src/main.tsx new file mode 100644 index 0000000..27481e0 --- /dev/null +++ b/panel/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/panel/src/pages/Account.tsx b/panel/src/pages/Account.tsx new file mode 100644 index 0000000..70da1b2 --- /dev/null +++ b/panel/src/pages/Account.tsx @@ -0,0 +1,196 @@ +import { useState, type FormEvent } from "react"; +import { CheckCircle2, Link2, ShieldCheck } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loading, ErrorState } from "@/components/States"; +import { api, humanizeError } from "@/lib/api"; +import { useAsync } from "@/lib/hooks"; + +// The Account page is the web half of the §10 link flow. A code is born in-game +// (online-mode auth proves the UUID) and consumed here (the session proves the +// user) — so this page only ever reports status and redeems a code; it can never +// originate a binding. The Minecraft-link card is a small state machine: checking +// → linked, or → the two-step "get a code in-game, enter it here" form. + +export function Account() { + const status = useAsync(() => api.linkStatus(), []); + + // Verify is a mutation, not a read, so it is hand-rolled (the useAsync producer + // is for the status read). On success we flip linked locally and keep the echoed + // UUID — the start endpoint never returns it, so this is the only confirmation + // of *which* account bound, shown without a refetch. + const [code, setCode] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [verifiedUUID, setVerifiedUUID] = useState(null); + + const linked = verifiedUUID !== null || status.data?.linked === true; + + async function submit(e: FormEvent) { + e.preventDefault(); + const trimmed = code.trim().toUpperCase(); + if (!trimmed || submitting) return; + setSubmitting(true); + setError(null); + try { + const res = await api.linkVerify(trimmed); + setVerifiedUUID(res.mc_uuid); + setCode(""); + } catch (err) { + setError(humanizeError(err)); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+

Account

+

+ Identity and Minecraft linking. +

+
+ + + + + Session + + + + You reached this panel through the platform's identity proxy (Zero-Trust / + Access). The panel itself holds no credentials — every request rides your + existing session cookie (spec §7/§8). + + + + + + + Minecraft link + + + + {linked ? ( + + ) : status.loading && !status.data ? ( + + ) : status.error ? ( + + ) : ( + + )} + + +
+ ); +} + +/** LinkedState confirms the binding. The UUID is shown only when this session + * just verified it (start does not return it), so a pre-existing link renders + * the confirmation without a UUID rather than inventing one. */ +function LinkedState({ uuid }: { uuid: string | null }) { + return ( +
+
+ + Your Minecraft account is linked. +
+

+ Linked sessions can claim and own servers — ownership operations (claim, + wake, stop) are unblocked. +

+ {uuid && ( +
+ UUID + + {uuid} + +
+ )} +
+ ); +} + +/** LinkForm is the two-step redemption UX: get a code in-game, then enter it. The + * input auto-uppercases for instant feedback; the server also trims + uppercases, + * so this is cosmetic, not the source of truth. */ +function LinkForm({ + code, + setCode, + submitting, + error, + onSubmit, +}: { + code: string; + setCode: (v: string) => void; + submitting: boolean; + error: string | null; + onSubmit: (e: FormEvent) => void; +}) { + return ( +
    +
  1. + +
    +

    Get a code in-game

    +

    + Join any server and run{" "} + + /link + {" "} + in chat. The server already knows your verified identity, so it hands + you a one-time code (valid about 10 minutes). +

    +
    +
  2. +
  3. + +
    +

    Enter it here

    +
    + +
    + setCode(e.target.value.toUpperCase())} + placeholder="ABCD2345" + autoComplete="off" + autoCapitalize="characters" + spellCheck={false} + maxLength={8} + className="max-w-[14rem] font-mono uppercase tracking-[0.3em]" + aria-invalid={error ? true : undefined} + /> + +
    + {error &&

    {error}

    } +
    +
    +
  4. +
+ ); +} + +function StepBadge({ n }: { n: number }) { + return ( + + {n} + + ); +} diff --git a/panel/src/pages/Dashboard.tsx b/panel/src/pages/Dashboard.tsx new file mode 100644 index 0000000..495a577 --- /dev/null +++ b/panel/src/pages/Dashboard.tsx @@ -0,0 +1,126 @@ +import { lazy, Suspense, useMemo } from "react"; +import { Link } from "react-router-dom"; +import { Activity, Server, Play, Hand } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Loading, ErrorState, EmptyState } from "@/components/States"; +import { api } from "@/lib/api"; +import { useAsync, useConfig } from "@/lib/hooks"; +import type { Phase, ServerInfo } from "@/lib/types"; + +// three.js is heavy and only the Dashboard renders it — split it into its own +// async chunk so the rest of the panel doesn't pay for it on first load. +const VoxelFleet = lazy(() => + import("@/components/VoxelFleet").then((m) => ({ default: m.VoxelFleet })), +); + +function Stat({ + icon: Icon, + label, + value, +}: { + icon: typeof Server; + label: string; + value: number | string; +}) { + return ( + + +
+ +
+
+
{value}
+
{label}
+
+
+
+ ); +} + +export function Dashboard() { + const cfg = useConfig(); + const { data, error, loading, reload } = useAsync(() => api.myServers(), []); + + const counts = useMemo(() => { + const servers = data ?? []; + const by = (p: Phase) => servers.filter((s) => s.phase === p).length; + return { + total: servers.length, + running: by("Running"), + players: servers.reduce((n, s) => n + (s.players ?? 0), 0), + }; + }, [data]); + + return ( +
+
+

Dashboard

+

Your fleet at a glance.

+
+ + {loading && !data ? ( + + ) : error ? ( + + ) : ( + + )} +
+ ); +} + +function FleetView({ + servers, + counts, + hasConfig, +}: { + servers: ServerInfo[]; + counts: { total: number; running: number; players: number }; + hasConfig: boolean; +}) { + if (servers.length === 0) { + return ( + + + + + + ); + } + + return ( + <> +
+ + + +
+ + + + Fleet + + + + + + {hasConfig ? ( +
+ }> + + +
+ ) : ( + + )} +
+
+ + ); +} diff --git a/panel/src/pages/MyServers.tsx b/panel/src/pages/MyServers.tsx new file mode 100644 index 0000000..b51243e --- /dev/null +++ b/panel/src/pages/MyServers.tsx @@ -0,0 +1,52 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { ServerCard } from "@/components/ServerCard"; +import { Loading, ErrorState, EmptyState } from "@/components/States"; +import { api } from "@/lib/api"; +import { useAsync, useConfig } from "@/lib/hooks"; + +// MyServers is the User-Side fleet: list / wake / stop / claim the servers the +// caller owns or may claim. Creation lives on Admin-Side now (POST /servers is +// admin-tier) — the platform provisions servers; users claim and operate them. +export function MyServers() { + const cfg = useConfig(); + const { data, error, loading, reload } = useAsync(() => api.myServers(), []); + const servers = data ?? []; + + return ( +
+
+

My servers

+

+ Wake, stop, or claim the servers you can manage. +

+
+ + {loading && !data ? ( + + ) : error ? ( + + ) : !cfg ? ( + + ) : servers.length === 0 ? ( + + ) : ( +
+ {servers.map((s) => ( + + ))} +
+ )} + + + + Servers and game types are provisioned and managed by the platform. You + claim a node to operate it; raw cluster config is never exposed here + (spec §15). + + +
+ ); +} diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx new file mode 100644 index 0000000..a2c5b7b --- /dev/null +++ b/panel/src/pages/ServerConsole.tsx @@ -0,0 +1,147 @@ +import { Link, useParams } from "react-router-dom"; +import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, type LucideIcon } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { PhaseBadge } from "@/components/PhaseBadge"; +import { LogConsole } from "@/components/LogConsole"; +import { Loading, ErrorState } from "@/components/States"; +import { api, consoleStreamURL } from "@/lib/api"; +import { useAsync, useConfig } from "@/lib/hooks"; +import { hostFor } from "@/lib/config"; +import type { Phase } from "@/lib/types"; + +// notStreamingCopy explains why there is no live feed for a phase that has no +// streamable pod. The read path only has something to relay once a pod is up, so +// Stopped/Failed/Stopping each get their own honest line rather than an empty +// console. `default` covers Unknown plus any future phase the backend may emit +// that the panel hasn't modelled yet — the screen stays informative regardless. +function notStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; body: string } { + switch (phase) { + case "Stopped": + return { + icon: Moon, + title: "Server is asleep", + body: "Wake it to boot a pod — the live console attaches automatically the moment it starts up.", + }; + case "Stopping": + return { + icon: Moon, + title: "Server is shutting down", + body: "The pod is terminating, so the live console has detached. It will be asleep in a moment.", + }; + case "Failed": + return { + icon: ShieldAlert, + title: "Server failed to start", + body: "No pod is running, so there is nothing to stream. Wake it to retry; the console attaches once a pod comes back up.", + }; + default: + return { + icon: HelpCircle, + title: "Console is offline", + body: "The live console attaches automatically as soon as the server is running.", + }; + } +} + +function NotStreaming({ phase }: { phase: Phase }) { + const { icon: Icon, title, body } = notStreamingCopy(phase); + return ( +
+ +
+

{title}

+

{body}

+
+
+ ); +} + +export function ServerConsole() { + const { name = "" } = useParams(); + const cfg = useConfig(); + const { data, error, loading, reload } = useAsync( + () => api.status(name), + [name], + ); + + // The read-side stream (spec §8) follows the running pod's log. A pod exists + // during BOTH Starting and Running — the operator marks Starting once the pod + // is up but RCON is not yet reachable (it only flips to Running after RCON + // readiness). Boot logs flow precisely in that Starting window, which is when a + // read most wants them, so the gate streams for both, not Running alone. + const streamable = data?.phase === "Running" || data?.phase === "Starting"; + + return ( +
+ + My servers + + + {loading && !data ? ( + + ) : error ? ( + + ) : data ? ( + <> +
+
+

+ {data.displayName || data.name} +

+ {cfg && ( +

+ {hostFor(data.subdomain, cfg)} +

+ )} +
+
+ + {streamable ? ( + + ) : ( + + )} +
+
+ + + + + Console + +

+ Live, read-only output streamed from the running pod over SSE + (spec §8). Commands run through a separate path — the panel never + holds an RCON password. +

+
+ + {!streamable ? ( + + ) : cfg ? ( + // key on name so navigating between servers remounts the viewport + // (fresh stream + scroll state). useLogStream also resets its + // controller when the url changes, so this is belt-and-suspenders. + + ) : ( + + )} + +
+ + ) : null} +
+ ); +} diff --git a/panel/src/pages/admin/AdminHome.tsx b/panel/src/pages/admin/AdminHome.tsx new file mode 100644 index 0000000..632aefb --- /dev/null +++ b/panel/src/pages/admin/AdminHome.tsx @@ -0,0 +1,66 @@ +import { Link } from "react-router-dom"; +import { ServerCog, Boxes, ShieldCheck } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { useTier } from "@/lib/tier"; + +// AdminHome is the Admin-Side landing: a short orientation plus links into the +// content/server-admin surfaces. It deliberately states the four-power boundary +// so an admin never expects cluster-lifecycle levers here — those stay kubectl/CRD. + +const LINKS = [ + { + to: "/admin/servers", + icon: ServerCog, + title: "Servers", + body: "Create platform servers from the structured §15 form and manage the ones you operate.", + }, + { + to: "/admin/images", + icon: Boxes, + title: "Images", + body: "The platform image whitelist — the value space the create form draws from.", + }, +]; + +export function AdminHome() { + const { identity } = useTier(); + + return ( +
+
+ +
+

Admin

+

+ Server & content administration + {identity?.email ? ` · signed in as ${identity.email}` : ""}. +

+
+
+ +
+ {LINKS.map((l) => ( + + + + +
{l.title}
+

{l.body}

+
+
+ + ))} +
+ + + + Cluster scaling, RBAC, Secrets and control-plane lifecycle are{" "} + kubectl / CRD operations and are + intentionally not available from the panel — the four-power separation + (build / runtime / operator / app) is preserved. This is a window onto the + platform, not a lever for operator power. + + +
+ ); +} diff --git a/panel/src/pages/admin/ImageAdmin.tsx b/panel/src/pages/admin/ImageAdmin.tsx new file mode 100644 index 0000000..f216e89 --- /dev/null +++ b/panel/src/pages/admin/ImageAdmin.tsx @@ -0,0 +1,67 @@ +import { Boxes, CheckCircle2, CircleSlash } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Loading, ErrorState, EmptyState } from "@/components/States"; +import { api } from "@/lib/api"; +import { useAsync } from "@/lib/hooks"; + +// ImageAdmin is the Admin-Side view of the platform image whitelist (spec §14 — +// "镜像白名单(Postgres, Web 动态改可审计)"). The READ is real (GET /images); the +// add / enable / disable WRITE routes are an admin-tier mutation slice still to be +// added, so this page surfaces the whitelist honestly and labels the missing +// mutation rather than shipping dead buttons. +export function ImageAdmin() { + const { data, error, loading, reload } = useAsync(() => api.listImages(), []); + const images = data ?? []; + + return ( +
+
+

Images

+

+ The platform image whitelist. Only enabled images can back a new server. + Editing the whitelist from the Web is auditable (spec §14) — the mutation + routes are a pending admin-tier slice, so this view is read-only for now. +

+
+ + {loading && !data ? ( + + ) : error ? ( + + ) : images.length === 0 ? ( + + ) : ( +
+ {images.map((img) => ( + + +
+ + {img.image_ref} + {img.source && ( + + {img.source} + + )} +
+ {img.enabled ? ( + + enabled + + ) : ( + + disabled + + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/panel/src/pages/admin/ServerAdmin.tsx b/panel/src/pages/admin/ServerAdmin.tsx new file mode 100644 index 0000000..21e81ff --- /dev/null +++ b/panel/src/pages/admin/ServerAdmin.tsx @@ -0,0 +1,64 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { ServerCard } from "@/components/ServerCard"; +import { CreateServerDialog } from "@/components/CreateServerDialog"; +import { Loading, ErrorState, EmptyState } from "@/components/States"; +import { api } from "@/lib/api"; +import { useAsync, useConfig } from "@/lib/hooks"; + +// ServerAdmin is the Admin-Side create surface. CreateServerDialog lives here (it +// calls the admin-tier POST /servers, spec §15) rather than on User-Side, where it +// previously rendered for everyone and relied on a 403 — moving it removes that +// UX/tier mismatch. +// +// The list below currently uses the app-tier GET /me/servers, i.e. the servers +// THIS admin owns or may manage. A fleet-wide GET /admin/servers (every server, +// not just mine) is a separate Oracle-verifiable backend slice that powers the +// SysAdmin-Side FleetTable; it is intentionally not faked in here. +export function ServerAdmin() { + const cfg = useConfig(); + const { data, error, loading, reload } = useAsync(() => api.myServers(), []); + const servers = data ?? []; + + return ( +
+
+
+

Servers

+

+ Create platform servers from the structured form and manage the ones + you operate. +

+
+ {cfg && } +
+ + {loading && !data ? ( + + ) : error ? ( + + ) : !cfg ? ( + + ) : servers.length === 0 ? ( + + ) : ( +
+ {servers.map((s) => ( + + ))} +
+ )} + + + + Server creation goes through the structured form only — the platform maps + your choices onto a vetted Kubernetes spec. Raw cluster config (host + networking, host paths, arbitrary images, privileged pods) is never + expressible here (spec §15). + + +
+ ); +} diff --git a/panel/src/pages/ops/FleetTable.tsx b/panel/src/pages/ops/FleetTable.tsx new file mode 100644 index 0000000..71f4d85 --- /dev/null +++ b/panel/src/pages/ops/FleetTable.tsx @@ -0,0 +1,27 @@ +import { Network } from "lucide-react"; +import { PendingBackend } from "@/components/States"; + +// FleetTable is the SysAdmin-Side cluster-wide server list: ALL servers with +// phase + capacity, distinct from the User-Side "My servers". It depends on the +// fleet-wide GET /admin/servers read, which is the next Oracle-verifiable backend +// slice. Honest placeholder until that lands — no fabricated rows. +export function FleetTable() { + return ( +
+
+ +
+

Fleet

+

+ Every server on the platform — phase, owner, capacity. +

+
+
+ + +
+ ); +} diff --git a/panel/src/pages/ops/OpsOverview.tsx b/panel/src/pages/ops/OpsOverview.tsx new file mode 100644 index 0000000..90091b4 --- /dev/null +++ b/panel/src/pages/ops/OpsOverview.tsx @@ -0,0 +1,57 @@ +import { Link } from "react-router-dom"; +import { Gauge, Network, ServerOff } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { PendingBackend } from "@/components/States"; + +// OpsOverview is the SysAdmin-Side landing: a platform-observability cockpit. It is +// admin-tier (same gate as Admin-Side) but a separate *concern* — read-mostly +// visibility across the whole platform, not content/server administration. +// +// The cluster-wide fleet rollup needs a fleet-wide read (GET /admin/servers) that +// does not exist yet — that is a separate Oracle-verifiable backend slice. Until +// then this surface is honest about the gap rather than charting fake data. +export function OpsOverview() { + return ( +
+
+ +
+

SysAdmin

+

+ Platform observability cockpit — a window, not a lever. +

+
+
+ + + +
+ Cluster-wide fleet +
+ + + Open fleet table → + +
+
+ + + + + + Control-plane scaling, RBAC, Secrets and cluster lifecycle are{" "} + kubectl / CRD operations and are not + reachable from here. SysAdmin-Side is observability only — the + four-power separation (build / runtime / operator / app) is preserved. + + + +
+ ); +} diff --git a/panel/src/vite-env.d.ts b/panel/src/vite-env.d.ts new file mode 100644 index 0000000..07e6c86 --- /dev/null +++ b/panel/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE?: string; + readonly VITE_ROOT_DOMAIN?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/panel/tailwind.config.js b/panel/tailwind.config.js new file mode 100644 index 0000000..db4e020 --- /dev/null +++ b/panel/tailwind.config.js @@ -0,0 +1,52 @@ +import tailwindcssAnimate from "tailwindcss-animate"; + +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: "class", + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "fade-in": { from: { opacity: "0" }, to: { opacity: "1" } }, + }, + animation: { "fade-in": "fade-in 0.2s ease-out" }, + }, + }, + plugins: [tailwindcssAnimate], +}; diff --git a/panel/tsconfig.app.json b/panel/tsconfig.app.json new file mode 100644 index 0000000..627920e --- /dev/null +++ b/panel/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2021", + "useDefineForClassFields": true, + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + "baseUrl": ".", + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src"] +} diff --git a/panel/tsconfig.json b/panel/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/panel/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/panel/tsconfig.node.json b/panel/tsconfig.node.json new file mode 100644 index 0000000..f1f1afc --- /dev/null +++ b/panel/tsconfig.node.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/panel/vite.config.ts b/panel/vite.config.ts new file mode 100644 index 0000000..5ac1683 --- /dev/null +++ b/panel/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { fileURLToPath, URL } from "node:url"; + +// The panel is served by felis-api behind Zero-Trust; the API lives under the +// same origin in production, so /api proxies there in dev. +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, + }, + server: { + proxy: { + "/api": { target: "http://localhost:8080", changeOrigin: true }, + }, + }, +});