✨ feat: Initial implementation
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
sample_data/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 YuzuZensai
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
683
bun.lock
Normal file
@@ -0,0 +1,683 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "vrc-one",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.0.6",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^25.6.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^16.1.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.28.4", "", {}, "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@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" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.11", "", { "os": "android", "cpu": "arm64" }, "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.11", "", { "os": "android", "cpu": "x64" }, "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.11", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.11", "", { "os": "linux", "cpu": "arm" }, "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.11", "", { "os": "linux", "cpu": "ia32" }, "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.11", "", { "os": "linux", "cpu": "x64" }, "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.11", "", { "os": "none", "cpu": "x64" }, "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.11", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.11", "", { "os": "openbsd", "cpu": "x64" }, "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.11", "", { "os": "sunos", "cpu": "x64" }, "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||
|
||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||
|
||||
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||
|
||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||
|
||||
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.5", "", { "os": "android", "cpu": "arm64" }, "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.5", "", { "os": "linux", "cpu": "arm" }, "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.5", "", { "os": "linux", "cpu": "x64" }, "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.5", "", { "os": "linux", "cpu": "x64" }, "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.5", "", { "os": "none", "cpu": "arm64" }, "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.8.0", "", {}, "sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.8.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.8.4", "@tauri-apps/cli-darwin-x64": "2.8.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.8.4", "@tauri-apps/cli-linux-arm64-gnu": "2.8.4", "@tauri-apps/cli-linux-arm64-musl": "2.8.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.8.4", "@tauri-apps/cli-linux-x64-gnu": "2.8.4", "@tauri-apps/cli-linux-x64-musl": "2.8.4", "@tauri-apps/cli-win32-arm64-msvc": "2.8.4", "@tauri-apps/cli-win32-ia32-msvc": "2.8.4", "@tauri-apps/cli-win32-x64-msvc": "2.8.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-ejUZBzuQRcjFV+v/gdj/DcbyX/6T4unZQjMSBZwLzP/CymEjKcc2+Fc8xTORThebHDUvqoXMdsCZt8r+hyN15g=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.8.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BKu8HRkYV01SMTa7r4fLx+wjgtRK8Vep7lmBdHDioP6b8XH3q2KgsAyPWfEZaZIkZ2LY4SqqGARaE9oilNe0oA=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.8.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-imb9PfSd/7G6VAO7v1bQ2A3ZH4NOCbhGJFLchxzepGcXf9NKkfun157JH9mko29K6sqAwuJ88qtzbKCbWJTH9g=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.8.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Ml215UnDdl7/fpOrF1CNovym/KjtUbCuPgrcZ4IhqUCnhZdXuphud/JT3E8X97Y03TZ40Sjz8raXYI2ET0exzw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.8.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pbcgBpMyI90C83CxE5REZ9ODyIlmmAPkkJXtV398X3SgZEIYy5TACYqlyyv2z5yKgD8F8WH4/2fek7+jH+ZXAw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.8.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-zumFeaU1Ws5Ay872FTyIm7z8kfzEHu8NcIn8M6TxbJs0a7GRV21KBdpW1zNj2qy7HynnpQCqjAYXTUUmm9JAOw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.8.4", "", { "os": "linux", "cpu": "none" }, "sha512-qiqbB3Zz6IyO201f+1ojxLj65WYj8mixL5cOMo63nlg8CIzsP23cPYUrx1YaDPsCLszKZo7tVs14pc7BWf+/aQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.8.4", "", { "os": "linux", "cpu": "x64" }, "sha512-TaqaDd9Oy6k45Hotx3pOf+pkbsxLaApv4rGd9mLuRM1k6YS/aw81YrsMryYPThrxrScEIUcmNIHaHsLiU4GMkw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.8.4", "", { "os": "linux", "cpu": "x64" }, "sha512-ot9STAwyezN8w+bBHZ+bqSQIJ0qPZFlz/AyscpGqB/JnJQVDFQcRDmUPFEaAtt2UUHSWzN3GoTJ5ypqLBp2WQA=="],
|
||||
|
||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.8.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-+2aJ/g90dhLiOLFSD1PbElXX3SoMdpO7HFPAZB+xot3CWlAZD1tReUFy7xe0L5GAR16ZmrxpIDM9v9gn5xRy/w=="],
|
||||
|
||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.8.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-yj7WDxkL1t9Uzr2gufQ1Hl7hrHuFKTNEOyascbc109EoiAqCp0tgZ2IykQqOZmZOHU884UAWI1pVMqBhS/BfhA=="],
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.8.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XuvGB4ehBdd7QhMZ9qbj/8icGEatDuBNxyYHbLKsTYh90ggUlPa/AtaqcC1Fo69lGkTmq9BOKrs1aWSi7xDonA=="],
|
||||
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "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" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.26.3", "", { "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", "electron-to-chromium": "^1.5.227", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w=="],
|
||||
|
||||
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="],
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "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" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||
|
||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.237", "", {}, "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "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" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||
|
||||
"i18next": ["i18next@25.6.0", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw=="],
|
||||
|
||||
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="],
|
||||
|
||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
||||
|
||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.25", "", {}, "sha512-4auku8B/vw5psvTiiN9j1dAOsXvMoGqJuKJcR+dTdqiXEK20mMTk1UEo3HS16LeGQsVG6+qKTPM9u/qQ2LqATA=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
|
||||
|
||||
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||
|
||||
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
|
||||
|
||||
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
|
||||
|
||||
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
|
||||
|
||||
"react-i18next": ["react-i18next@16.1.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 25.5.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-10qpoODc0ntrWpZdko9CQnl2Xf4VJXUAS9sa7WOkTrQzbbfyoSSUZ3Rsnpgt8la6qCxeB61yzYjyW8U1g3hUUg=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "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" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-router": ["react-router@7.9.4", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA=="],
|
||||
|
||||
"react-router-dom": ["react-router-dom@7.9.4", "", { "dependencies": { "react-router": "7.9.4" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||
|
||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
|
||||
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@3.4.18", "", { "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" } }, "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ=="],
|
||||
|
||||
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
||||
|
||||
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
}
|
||||
}
|
||||
17
components.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/tauri.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VRC Circle</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
54
package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "vrc-circle",
|
||||
"author": "Yuzu <yuzu@kirameki.cafe>",
|
||||
"license": "MIT",
|
||||
"private": false,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"generate:bindings": "cargo run --manifest-path src-tauri/Cargo.toml --bin vrc-circle -- --generate-bindings"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.0.6",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^25.6.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^16.1.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184"
|
||||
}
|
||||
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
6
public/tauri.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
7110
src-tauri/Cargo.lock
generated
Normal file
46
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "vrc-circle"
|
||||
version = "0.0.1"
|
||||
description = "A Tauri App"
|
||||
authors = ["Yuzu <yuzu@kirameki.cafe>"]
|
||||
edition = "2024"
|
||||
default-run = "vrc-circle"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "vrc_one_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
specta = { version = "2.0.0-rc.20", features = ["serde"] }
|
||||
specta-typescript = "0.0.9"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-log = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "cookies"] }
|
||||
base64 = "0.22"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||
futures-util = "0.3"
|
||||
http = "1.1"
|
||||
directories = "5.0"
|
||||
dirs = "5.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
specta = { version = "2.0.0-rc.20", features = ["serde"] }
|
||||
tauri-specta = { version = "2.0.0-rc.20", features = ["typescript"] }
|
||||
specta-typescript = "0.0.9"
|
||||
sea-orm = { version = "0.12", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"] }
|
||||
log = "0.4"
|
||||
sha2 = "0.10"
|
||||
url = "2"
|
||||
4
src-tauri/build.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
12
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"core:path:default",
|
||||
"core:image:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
256
src-tauri/src/database_studio.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
// TODO: Improve this dev tool
|
||||
|
||||
use sea_orm::{
|
||||
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, Statement,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct TableInfo {
|
||||
pub name: String,
|
||||
pub sql: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct ColumnInfo {
|
||||
pub cid: i32,
|
||||
pub name: String,
|
||||
pub r#type: String,
|
||||
pub notnull: i32,
|
||||
pub dflt_value: Option<String>,
|
||||
pub pk: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct QueryResult {
|
||||
pub columns: Vec<String>,
|
||||
#[specta(type = Vec<HashMap<String, String>>)]
|
||||
pub rows: Vec<HashMap<String, String>>,
|
||||
pub rows_affected: Option<i32>,
|
||||
}
|
||||
|
||||
pub struct DatabaseStudio {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl DatabaseStudio {
|
||||
pub async fn new() -> Result<Self, String> {
|
||||
// Use per-user local data directory (e.g. %LOCALAPPDATA% on Windows)
|
||||
let base_dir = dirs::data_local_dir()
|
||||
.ok_or("Failed to resolve local data directory")?
|
||||
.join("vrc-circle");
|
||||
|
||||
std::fs::create_dir_all(&base_dir)
|
||||
.map_err(|e| format!("Failed to create app data directory: {}", e))?;
|
||||
|
||||
let db_path = base_dir.join("data.sqlite");
|
||||
let db_url = format!(
|
||||
"sqlite://{}?mode=rwc",
|
||||
db_path.to_string_lossy().replace('\\', "/")
|
||||
);
|
||||
|
||||
let mut options = ConnectOptions::new(db_url);
|
||||
options.sqlx_logging(false);
|
||||
|
||||
let db = Database::connect(options)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect to database: {}", e))?;
|
||||
|
||||
Ok(Self { db })
|
||||
}
|
||||
|
||||
pub async fn list_tables(&self) -> Result<Vec<TableInfo>, String> {
|
||||
let query = Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
"SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let result = self
|
||||
.db
|
||||
.query_all(query)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list tables: {}", e))?;
|
||||
|
||||
let tables: Vec<TableInfo> = result
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let name: String = row.try_get("", "name").unwrap_or_default();
|
||||
let sql: String = row.try_get("", "sql").unwrap_or_default();
|
||||
TableInfo { name, sql }
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(tables)
|
||||
}
|
||||
|
||||
pub async fn get_table_schema(&self, table_name: &str) -> Result<Vec<ColumnInfo>, String> {
|
||||
let query = Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
format!("PRAGMA table_info('{}')", table_name),
|
||||
);
|
||||
|
||||
let result = self
|
||||
.db
|
||||
.query_all(query)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get table schema: {}", e))?;
|
||||
|
||||
let columns: Vec<ColumnInfo> = result
|
||||
.into_iter()
|
||||
.map(|row| ColumnInfo {
|
||||
cid: row.try_get("", "cid").unwrap_or_default(),
|
||||
name: row.try_get("", "name").unwrap_or_default(),
|
||||
r#type: row.try_get("", "type").unwrap_or_default(),
|
||||
notnull: row.try_get("", "notnull").unwrap_or_default(),
|
||||
dflt_value: row.try_get("", "dflt_value").ok(),
|
||||
pk: row.try_get("", "pk").unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(columns)
|
||||
}
|
||||
|
||||
pub async fn get_table_data(
|
||||
&self,
|
||||
table_name: &str,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
) -> Result<QueryResult, String> {
|
||||
let limit_val = limit.unwrap_or(100);
|
||||
let offset_val = offset.unwrap_or(0);
|
||||
|
||||
let query_str = format!(
|
||||
"SELECT * FROM {} LIMIT {} OFFSET {}",
|
||||
table_name, limit_val, offset_val
|
||||
);
|
||||
|
||||
let mut result = self.execute_query(&query_str).await?;
|
||||
|
||||
// Get actual column names from schema and remap the data
|
||||
let schema = self.get_table_schema(table_name).await?;
|
||||
let actual_columns: Vec<String> = schema.into_iter().map(|col| col.name).collect();
|
||||
|
||||
// Remap rows from column_0, column_1 to actual column names
|
||||
let remapped_rows: Vec<HashMap<String, String>> = result
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let mut new_row = HashMap::new();
|
||||
for (idx, actual_col) in actual_columns.iter().enumerate() {
|
||||
let key = format!("column_{}", idx);
|
||||
if let Some(value) = row.get(&key) {
|
||||
new_row.insert(actual_col.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
new_row
|
||||
})
|
||||
.collect();
|
||||
|
||||
result.columns = actual_columns;
|
||||
result.rows = remapped_rows;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn get_table_count(&self, table_name: &str) -> Result<i32, String> {
|
||||
let query = Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
format!("SELECT COUNT(*) as count FROM {}", table_name),
|
||||
);
|
||||
|
||||
let result = self
|
||||
.db
|
||||
.query_one(query)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to count rows: {}", e))?;
|
||||
|
||||
if let Some(row) = result {
|
||||
let count: i64 = row.try_get("", "count").unwrap_or_default();
|
||||
Ok(count as i32)
|
||||
} else {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_query(&self, query: &str) -> Result<QueryResult, String> {
|
||||
use sea_orm::TryGetable;
|
||||
|
||||
let query_stmt = Statement::from_string(DbBackend::Sqlite, query.to_string());
|
||||
|
||||
let result = self
|
||||
.db
|
||||
.query_all(query_stmt)
|
||||
.await
|
||||
.map_err(|e| format!("Query execution failed: {}", e))?;
|
||||
|
||||
if result.is_empty() {
|
||||
return Ok(QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
rows_affected: Some(0),
|
||||
});
|
||||
}
|
||||
|
||||
// Extract column names and convert rows
|
||||
let mut columns: Vec<String> = Vec::new();
|
||||
let mut rows: Vec<HashMap<String, String>> = Vec::new();
|
||||
|
||||
for row in result {
|
||||
// Get columns from first row by trying all possible indices
|
||||
if columns.is_empty() {
|
||||
let mut idx = 0;
|
||||
loop {
|
||||
// Try to get as String first, then fall back to other types
|
||||
match String::try_get_by_index(&row, idx) {
|
||||
Ok(_) => {
|
||||
idx += 1;
|
||||
}
|
||||
Err(_) => {
|
||||
// Try as i64
|
||||
match i64::try_get_by_index(&row, idx) {
|
||||
Ok(_) => {
|
||||
idx += 1;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// We don't know column names yet, will be set by get_table_data
|
||||
columns = (0..idx).map(|i| format!("column_{}", i)).collect();
|
||||
}
|
||||
|
||||
// Convert row to HashMap with String values by index
|
||||
let mut map = HashMap::new();
|
||||
for idx in 0..columns.len() {
|
||||
// Try multiple types and convert to string
|
||||
let str_value = if let Ok(val) = String::try_get_by_index(&row, idx) {
|
||||
val
|
||||
} else if let Ok(val) = i64::try_get_by_index(&row, idx) {
|
||||
val.to_string()
|
||||
} else if let Ok(val) = f64::try_get_by_index(&row, idx) {
|
||||
val.to_string()
|
||||
} else if let Ok(val) = bool::try_get_by_index(&row, idx) {
|
||||
val.to_string()
|
||||
} else if let Ok(Some(val)) = Option::<String>::try_get_by_index(&row, idx) {
|
||||
val
|
||||
} else {
|
||||
"NULL".to_string()
|
||||
};
|
||||
|
||||
let key = format!("column_{}", idx);
|
||||
map.insert(key, str_value);
|
||||
}
|
||||
rows.push(map);
|
||||
}
|
||||
|
||||
Ok(QueryResult {
|
||||
columns,
|
||||
rows,
|
||||
rows_affected: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
35
src-tauri/src/http_common.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
pub const USER_AGENT_STRING: &str = "VRC-Circle/0.0.1 contact@kirameki.cafe";
|
||||
|
||||
// Retry configuration
|
||||
pub const MAX_REQUEST_RETRIES: u8 = 5;
|
||||
|
||||
// Backoff timings (milliseconds)
|
||||
pub const INITIAL_BACKOFF: u64 = 500;
|
||||
pub const MAX_BACKOFF: u64 = 10_000;
|
||||
|
||||
// Rate limiting
|
||||
pub const MAX_DOWNLOADS_PER_SECOND: u32 = 10;
|
||||
|
||||
// Common API Request headers builder
|
||||
use reqwest::header::{
|
||||
ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, ORIGIN, USER_AGENT,
|
||||
};
|
||||
|
||||
pub fn build_api_headers(auth: Option<&str>, cookie: Option<&str>) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_STRING));
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
headers.insert(ORIGIN, HeaderValue::from_static("https://vrchat.com"));
|
||||
|
||||
if let Some(auth_value) = auth {
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_str(auth_value).unwrap());
|
||||
}
|
||||
|
||||
if let Some(cookie_value) = cookie {
|
||||
headers.insert("cookie", HeaderValue::from_str(cookie_value).unwrap());
|
||||
}
|
||||
|
||||
headers
|
||||
}
|
||||
771
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,771 @@
|
||||
pub mod database_studio;
|
||||
pub mod http_common;
|
||||
pub mod log_manager;
|
||||
pub mod store;
|
||||
pub mod vrchat_api;
|
||||
pub mod vrchat_status;
|
||||
pub mod websocket;
|
||||
|
||||
use database_studio::{ColumnInfo, DatabaseStudio, QueryResult, TableInfo};
|
||||
use log::info;
|
||||
use log_manager::{LogEntry, LogManager};
|
||||
use std::sync::Arc;
|
||||
use store::{AccountStore, AppSettings, ImageCacheStore, SettingsStore, StoredAccount, UserStore};
|
||||
use tauri::{Manager, State};
|
||||
use tauri_specta::{Builder as SpectaBuilder, collect_commands};
|
||||
use tokio::sync::Mutex;
|
||||
use vrchat_api::{
|
||||
AgeVerificationStatus, AvatarPerformance, AvatarStyles, Badge, DeveloperType, DiscordDetails,
|
||||
FriendRequestStatus, GoogleDetails, LimitedAvatar, LimitedUserFriend, LimitedWorld,
|
||||
LoginCredentials, LoginResult, OrderOption, PastDisplayName, PerformanceRatings, ReleaseStatus,
|
||||
SteamDetails, TwoFactorMethod, UnityPackageSummary, UpdateStatusRequest, User, UserState,
|
||||
UserStatus, VRCError, VRChatClient,
|
||||
};
|
||||
use vrchat_status::{StatusPage, SystemStatus, VRChatStatusResponse};
|
||||
use websocket::VRChatWebSocket;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
use specta_typescript::Typescript;
|
||||
|
||||
// Application State
|
||||
struct AppState {
|
||||
vrchat_client: Arc<Mutex<VRChatClient>>,
|
||||
account_store: AccountStore,
|
||||
websocket: Arc<Mutex<VRChatWebSocket>>,
|
||||
user_store: UserStore,
|
||||
settings_store: SettingsStore,
|
||||
#[allow(dead_code)]
|
||||
image_cache: Arc<ImageCacheStore>,
|
||||
}
|
||||
|
||||
// VRChat API Commands
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn vrchat_login(
|
||||
email: String,
|
||||
password: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<LoginResult, VRCError> {
|
||||
let credentials = LoginCredentials { email, password };
|
||||
let client = state.vrchat_client.lock().await;
|
||||
|
||||
client.login(&credentials).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn vrchat_verify_2fa(
|
||||
code: String,
|
||||
method: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, VRCError> {
|
||||
let two_fa_method = TwoFactorMethod::from_str(&method)
|
||||
.ok_or_else(|| VRCError::invalid_input(format!("Invalid 2FA method: {}", method)))?;
|
||||
|
||||
let client = state.vrchat_client.lock().await;
|
||||
|
||||
client.verify_two_factor(&code, two_fa_method).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn vrchat_get_current_user(state: State<'_, AppState>) -> Result<User, VRCError> {
|
||||
if let Some(user) = state.user_store.get_current_user().await {
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
let client = state.vrchat_client.lock().await;
|
||||
let user = client.get_current_user().await?;
|
||||
|
||||
state.user_store.set_current_user(user.clone()).await;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn vrchat_update_status(
|
||||
status: UserStatus,
|
||||
status_description: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<User, VRCError> {
|
||||
let request = UpdateStatusRequest {
|
||||
status,
|
||||
status_description,
|
||||
};
|
||||
|
||||
let client = state.vrchat_client.lock().await;
|
||||
let user = client.update_status(&request).await?;
|
||||
drop(client);
|
||||
|
||||
state.user_store.set_current_user(user.clone()).await;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn vrchat_logout(state: State<'_, AppState>) -> Result<(), VRCError> {
|
||||
let websocket = state.websocket.lock().await;
|
||||
websocket.stop().await;
|
||||
drop(websocket);
|
||||
|
||||
state.user_store.clear_all().await;
|
||||
|
||||
let client = state.vrchat_client.lock().await;
|
||||
client.logout().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn websocket_start(state: State<'_, AppState>) -> Result<(), VRCError> {
|
||||
let client = state.vrchat_client.lock().await;
|
||||
let (auth_cookie, two_factor_cookie) = client.export_cookies().await;
|
||||
drop(client);
|
||||
|
||||
log::debug!(
|
||||
"WebSocket starting with cookies - auth: {:?}, 2fa: {:?}",
|
||||
auth_cookie
|
||||
.as_ref()
|
||||
.map(|c| format!("{}...", &c.chars().take(20).collect::<String>())),
|
||||
two_factor_cookie
|
||||
.as_ref()
|
||||
.map(|c| format!("{}...", &c.chars().take(20).collect::<String>()))
|
||||
);
|
||||
|
||||
let websocket = state.websocket.lock().await;
|
||||
websocket.set_cookies(auth_cookie, two_factor_cookie).await;
|
||||
websocket.start().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn websocket_stop(state: State<'_, AppState>) -> Result<(), VRCError> {
|
||||
let websocket = state.websocket.lock().await;
|
||||
websocket.stop().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn vrchat_get_online_friends(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<LimitedUserFriend>, VRCError> {
|
||||
let cached_friends = state.user_store.get_all_friends().await;
|
||||
|
||||
if !cached_friends.is_empty() {
|
||||
return Ok(cached_friends);
|
||||
}
|
||||
|
||||
let client = state.vrchat_client.lock().await;
|
||||
let friends = client.get_all_friends().await?;
|
||||
|
||||
state.user_store.set_friends(friends.clone()).await;
|
||||
Ok(friends)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn vrchat_get_uploaded_worlds(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<LimitedWorld>, VRCError> {
|
||||
let client = state.vrchat_client.lock().await;
|
||||
client.get_uploaded_worlds().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn vrchat_get_uploaded_avatars(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<LimitedAvatar>, VRCError> {
|
||||
let client = state.vrchat_client.lock().await;
|
||||
client.get_uploaded_avatars().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn get_online_friends(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<LimitedUserFriend>, VRCError> {
|
||||
Ok(state.user_store.get_all_friends().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn check_image_cached(
|
||||
url: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<String>, VRCError> {
|
||||
if url.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let cache = state.image_cache.clone();
|
||||
if let Some(path) = cache.get_cached_path(&url).await {
|
||||
Ok(Some(path.to_string_lossy().to_string()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn cache_image(url: String, state: State<'_, AppState>) -> Result<String, VRCError> {
|
||||
if url.trim().is_empty() {
|
||||
return Err(VRCError::invalid_input("Image URL is empty".to_string()));
|
||||
}
|
||||
|
||||
info!("Caching image: {}", url);
|
||||
|
||||
let client = state.vrchat_client.lock().await;
|
||||
let (auth_cookie, two_factor_cookie) = client.export_cookies().await;
|
||||
drop(client);
|
||||
|
||||
let cookies = match (auth_cookie, two_factor_cookie) {
|
||||
(Some(auth), Some(two_fa)) => Some(format!("{}; {}", auth, two_fa)),
|
||||
(Some(auth), None) => Some(auth),
|
||||
(None, Some(two_fa)) => Some(two_fa),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
let cache = state.image_cache.clone();
|
||||
let path = cache
|
||||
.get_or_fetch(&url, cookies)
|
||||
.await
|
||||
.map_err(VRCError::unknown)?;
|
||||
info!("Image cached to: {}", path.display());
|
||||
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn get_cache_directory(state: State<'_, AppState>) -> Result<String, VRCError> {
|
||||
let cache = state.image_cache.clone();
|
||||
let dir = cache.get_cache_dir();
|
||||
Ok(dir.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn get_all_friends(state: State<'_, AppState>) -> Result<Vec<LimitedUserFriend>, VRCError> {
|
||||
Ok(state.user_store.get_all_friends().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn get_user(
|
||||
user_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<LimitedUserFriend>, VRCError> {
|
||||
Ok(state.user_store.get_user(&user_id).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn get_user_by_id(user_id: String, state: State<'_, AppState>) -> Result<User, VRCError> {
|
||||
if let Some(cached_user) = state.user_store.get_full_user(&user_id).await {
|
||||
log::debug!("get_user_by_id: Returning cached user for {}", user_id);
|
||||
return Ok(cached_user);
|
||||
}
|
||||
|
||||
log::info!("get_user_by_id: Fetching user {} from API", user_id);
|
||||
|
||||
let client = state.vrchat_client.lock().await;
|
||||
let user = client.get_user_by_id(&user_id).await?;
|
||||
drop(client);
|
||||
|
||||
log::info!("get_user_by_id: Successfully fetched user {}", user_id);
|
||||
|
||||
state.user_store.cache_full_user(user.clone()).await;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn is_friend(user_id: String, state: State<'_, AppState>) -> Result<bool, VRCError> {
|
||||
Ok(state.user_store.is_friend(&user_id).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn is_user_online(user_id: String, state: State<'_, AppState>) -> Result<bool, VRCError> {
|
||||
Ok(state.user_store.is_user_online(&user_id).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn vrchat_check_session(state: State<'_, AppState>) -> Result<bool, VRCError> {
|
||||
let client = state.vrchat_client.lock().await;
|
||||
Ok(client.has_valid_session().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn vrchat_clear_session(state: State<'_, AppState>) -> Result<(), VRCError> {
|
||||
let client = state.vrchat_client.lock().await;
|
||||
client.clear_cookies().await;
|
||||
drop(client);
|
||||
|
||||
state.user_store.clear_all().await;
|
||||
|
||||
state
|
||||
.account_store
|
||||
.clear_last_active_account()
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Account Management Commands
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn save_current_account(user: User, state: State<'_, AppState>) -> Result<(), VRCError> {
|
||||
let client = state.vrchat_client.lock().await;
|
||||
let (auth_cookie, two_factor_cookie) = client.export_cookies().await;
|
||||
|
||||
let avatar_override = user
|
||||
.user_icon
|
||||
.clone()
|
||||
.or_else(|| user.profile_pic_override.clone())
|
||||
.or_else(|| user.profile_pic_override_thumbnail.clone());
|
||||
|
||||
let avatar_fallback = user
|
||||
.current_avatar_thumbnail_image_url
|
||||
.clone()
|
||||
.or_else(|| user.current_avatar_image_url.clone());
|
||||
|
||||
let account = StoredAccount {
|
||||
user_id: user.id.clone(),
|
||||
username: user.username.clone(),
|
||||
display_name: user.display_name.clone(),
|
||||
avatar_url: avatar_override.clone().or_else(|| avatar_fallback.clone()),
|
||||
avatar_fallback_url: avatar_fallback,
|
||||
auth_cookie,
|
||||
two_factor_cookie,
|
||||
last_login: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
state
|
||||
.account_store
|
||||
.save_account(account)
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn get_all_accounts(state: State<'_, AppState>) -> Result<Vec<StoredAccount>, VRCError> {
|
||||
state
|
||||
.account_store
|
||||
.get_all_accounts()
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn switch_account(user_id: String, state: State<'_, AppState>) -> Result<User, VRCError> {
|
||||
state.user_store.clear_all().await;
|
||||
|
||||
let account = state
|
||||
.account_store
|
||||
.get_account(&user_id)
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))?
|
||||
.ok_or_else(|| VRCError::invalid_input("Account not found"))?;
|
||||
|
||||
state
|
||||
.account_store
|
||||
.set_active_account(&user_id)
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))?;
|
||||
|
||||
let client = state.vrchat_client.lock().await;
|
||||
client
|
||||
.import_cookies(account.auth_cookie, account.two_factor_cookie)
|
||||
.await;
|
||||
|
||||
match client.get_current_user().await {
|
||||
Ok(user) => {
|
||||
state.user_store.set_current_user(user.clone()).await;
|
||||
Ok(user)
|
||||
}
|
||||
Err(err) => {
|
||||
// TODO: Handle if switching account fails
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn remove_account(user_id: String, state: State<'_, AppState>) -> Result<(), VRCError> {
|
||||
state
|
||||
.account_store
|
||||
.remove_account(&user_id)
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn clear_all_accounts(state: State<'_, AppState>) -> Result<(), VRCError> {
|
||||
state
|
||||
.account_store
|
||||
.clear_all_accounts()
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn load_last_account(state: State<'_, AppState>) -> Result<Option<User>, VRCError> {
|
||||
let account = match state
|
||||
.account_store
|
||||
.get_last_active_account()
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))?
|
||||
{
|
||||
Some(acc) => acc,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let client = state.vrchat_client.lock().await;
|
||||
client
|
||||
.import_cookies(account.auth_cookie, account.two_factor_cookie)
|
||||
.await;
|
||||
|
||||
match client.get_current_user().await {
|
||||
Ok(user) => Ok(Some(user)),
|
||||
Err(_) => {
|
||||
// TODO: Handle if last account cannot be loaded
|
||||
// client.clear_cookies().await;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings Commands
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, VRCError> {
|
||||
state
|
||||
.settings_store
|
||||
.get_settings()
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn save_settings(settings: AppSettings, state: State<'_, AppState>) -> Result<(), VRCError> {
|
||||
info!(
|
||||
"Saving settings: developer_mode={}",
|
||||
settings.developer_mode
|
||||
);
|
||||
state
|
||||
.settings_store
|
||||
.save_settings(settings)
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn get_developer_mode(state: State<'_, AppState>) -> Result<bool, VRCError> {
|
||||
state
|
||||
.settings_store
|
||||
.get_developer_mode()
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn set_developer_mode(enabled: bool, state: State<'_, AppState>) -> Result<(), VRCError> {
|
||||
info!("Setting developer mode: {}", enabled);
|
||||
state
|
||||
.settings_store
|
||||
.set_developer_mode(enabled)
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
// Log Commands
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn get_backend_logs() -> Result<Vec<LogEntry>, VRCError> {
|
||||
LogManager::read_logs().map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn clear_backend_logs() -> Result<(), VRCError> {
|
||||
info!("Clearing backend logs");
|
||||
LogManager::clear_logs().map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn export_backend_logs() -> Result<String, VRCError> {
|
||||
LogManager::export_logs().map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
// VRChat Service Status Commands
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn get_vrchat_status() -> Result<VRChatStatusResponse, String> {
|
||||
vrchat_status::fetch_vrchat_status().await
|
||||
}
|
||||
|
||||
// Database Studio Commands
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn db_list_tables() -> Result<Vec<TableInfo>, VRCError> {
|
||||
let studio = DatabaseStudio::new()
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))?;
|
||||
|
||||
studio.list_tables().await.map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn db_get_table_schema(table_name: String) -> Result<Vec<ColumnInfo>, VRCError> {
|
||||
let studio = DatabaseStudio::new()
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))?;
|
||||
|
||||
studio
|
||||
.get_table_schema(&table_name)
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn db_get_table_data(
|
||||
table_name: String,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
) -> Result<QueryResult, VRCError> {
|
||||
let studio = DatabaseStudio::new()
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))?;
|
||||
|
||||
studio
|
||||
.get_table_data(&table_name, limit, offset)
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn db_get_table_count(table_name: String) -> Result<i32, VRCError> {
|
||||
let studio = DatabaseStudio::new()
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))?;
|
||||
|
||||
studio
|
||||
.get_table_count(&table_name)
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn db_execute_query(query: String) -> Result<QueryResult, VRCError> {
|
||||
let studio = DatabaseStudio::new()
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))?;
|
||||
|
||||
studio
|
||||
.execute_query(&query)
|
||||
.await
|
||||
.map_err(|e| VRCError::unknown(e))
|
||||
}
|
||||
|
||||
// Binding Generation
|
||||
fn create_specta_builder() -> SpectaBuilder<tauri::Wry> {
|
||||
SpectaBuilder::<tauri::Wry>::new()
|
||||
.commands(collect_commands![
|
||||
vrchat_login,
|
||||
vrchat_verify_2fa,
|
||||
vrchat_get_current_user,
|
||||
vrchat_update_status,
|
||||
vrchat_logout,
|
||||
vrchat_get_online_friends,
|
||||
vrchat_get_uploaded_worlds,
|
||||
vrchat_get_uploaded_avatars,
|
||||
get_online_friends,
|
||||
get_all_friends,
|
||||
get_user,
|
||||
get_user_by_id,
|
||||
is_friend,
|
||||
is_user_online,
|
||||
vrchat_check_session,
|
||||
vrchat_clear_session,
|
||||
websocket_start,
|
||||
websocket_stop,
|
||||
save_current_account,
|
||||
get_all_accounts,
|
||||
switch_account,
|
||||
remove_account,
|
||||
clear_all_accounts,
|
||||
load_last_account,
|
||||
get_settings,
|
||||
save_settings,
|
||||
get_developer_mode,
|
||||
set_developer_mode,
|
||||
get_backend_logs,
|
||||
clear_backend_logs,
|
||||
export_backend_logs,
|
||||
get_vrchat_status,
|
||||
db_list_tables,
|
||||
db_get_table_schema,
|
||||
db_get_table_data,
|
||||
db_get_table_count,
|
||||
db_execute_query,
|
||||
check_image_cached,
|
||||
cache_image,
|
||||
get_cache_directory,
|
||||
])
|
||||
// Core VRChat API types
|
||||
.typ::<VRCError>()
|
||||
.typ::<User>()
|
||||
.typ::<LoginResult>()
|
||||
.typ::<UpdateStatusRequest>()
|
||||
// Enum types
|
||||
.typ::<UserStatus>()
|
||||
.typ::<ReleaseStatus>()
|
||||
.typ::<DeveloperType>()
|
||||
.typ::<AgeVerificationStatus>()
|
||||
.typ::<FriendRequestStatus>()
|
||||
.typ::<UserState>()
|
||||
.typ::<PerformanceRatings>()
|
||||
.typ::<OrderOption>()
|
||||
// User-related types
|
||||
.typ::<LimitedUserFriend>()
|
||||
.typ::<PastDisplayName>()
|
||||
.typ::<Badge>()
|
||||
.typ::<DiscordDetails>()
|
||||
.typ::<GoogleDetails>()
|
||||
.typ::<SteamDetails>()
|
||||
// World types
|
||||
.typ::<LimitedWorld>()
|
||||
.typ::<UnityPackageSummary>()
|
||||
// Avatar types
|
||||
.typ::<LimitedAvatar>()
|
||||
.typ::<AvatarPerformance>()
|
||||
.typ::<AvatarStyles>()
|
||||
// Store types
|
||||
.typ::<StoredAccount>()
|
||||
.typ::<AppSettings>()
|
||||
// Log types
|
||||
.typ::<LogEntry>()
|
||||
// Database types
|
||||
.typ::<TableInfo>()
|
||||
.typ::<ColumnInfo>()
|
||||
.typ::<QueryResult>()
|
||||
// Status types
|
||||
.typ::<VRChatStatusResponse>()
|
||||
.typ::<SystemStatus>()
|
||||
.typ::<StatusPage>()
|
||||
}
|
||||
|
||||
pub fn generate_bindings() {
|
||||
use std::{fs, fs::File, io::Write};
|
||||
eprintln!("Generating TypeScript bindings...");
|
||||
|
||||
let specta_builder = create_specta_builder();
|
||||
let formatter = Typescript::default();
|
||||
let bindings = specta_builder
|
||||
.export_str(&formatter)
|
||||
.expect("Failed to generate TypeScript bindings");
|
||||
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let output_path = manifest_dir
|
||||
.join("..")
|
||||
.join("src")
|
||||
.join("types")
|
||||
.join("bindings.ts");
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent).expect("Failed to create bindings directory");
|
||||
}
|
||||
|
||||
let mut file = File::create(&output_path).expect("Failed to create TypeScript bindings file");
|
||||
file.write_all(
|
||||
b"// @ts-nocheck\n// This file is auto-generated by Specta. Do not edit manually.\n\n",
|
||||
)
|
||||
.expect("Failed to write bindings header");
|
||||
file.write_all(bindings.as_bytes())
|
||||
.expect("Failed to write TypeScript bindings");
|
||||
|
||||
formatter.format(&output_path).ok();
|
||||
eprintln!(
|
||||
"Successfully generated bindings at {}",
|
||||
output_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Application Entry Point
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let specta_builder = create_specta_builder();
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
eprintln!("Auto-generating TypeScript bindings in development mode...");
|
||||
generate_bindings();
|
||||
}
|
||||
|
||||
let vrchat_client = VRChatClient::new().expect("Failed to create VRChat client");
|
||||
let account_store =
|
||||
tauri::async_runtime::block_on(AccountStore::new()).expect("Failed to create AccountStore");
|
||||
let settings_store = tauri::async_runtime::block_on(SettingsStore::new())
|
||||
.expect("Failed to create SettingsStore");
|
||||
let image_cache = Arc::new(
|
||||
tauri::async_runtime::block_on(ImageCacheStore::new())
|
||||
.expect("Failed to create ImageCacheStore"),
|
||||
);
|
||||
let user_store = UserStore::new();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(
|
||||
tauri_plugin_log::Builder::new()
|
||||
.target(tauri_plugin_log::Target::new(
|
||||
tauri_plugin_log::TargetKind::Stdout,
|
||||
))
|
||||
.target(tauri_plugin_log::Target::new(
|
||||
tauri_plugin_log::TargetKind::LogDir {
|
||||
file_name: Some("vrc-circle".to_string()),
|
||||
},
|
||||
))
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)
|
||||
.invoke_handler(specta_builder.invoke_handler())
|
||||
.setup(move |app| {
|
||||
// Initialize WebSocket with app handle and UserStore
|
||||
let websocket = VRChatWebSocket::new(app.handle().clone(), user_store.clone());
|
||||
|
||||
let app_state = AppState {
|
||||
vrchat_client: Arc::new(Mutex::new(vrchat_client)),
|
||||
account_store,
|
||||
websocket: Arc::new(Mutex::new(websocket)),
|
||||
user_store,
|
||||
settings_store,
|
||||
image_cache: image_cache.clone(),
|
||||
};
|
||||
|
||||
app.manage(app_state);
|
||||
specta_builder.mount_events(app);
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
101
src-tauri/src/log_manager.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp: String,
|
||||
pub level: String,
|
||||
pub source: String,
|
||||
pub module: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub struct LogManager;
|
||||
|
||||
impl LogManager {
|
||||
pub fn get_log_file_path() -> Result<PathBuf, String> {
|
||||
let log_dir = dirs::data_local_dir()
|
||||
.ok_or("Failed to get local data directory")?
|
||||
.join("cafe.kirameki.vrc-circle")
|
||||
.join("logs");
|
||||
|
||||
fs::create_dir_all(&log_dir)
|
||||
.map_err(|e| format!("Failed to create logs directory: {}", e))?;
|
||||
|
||||
Ok(log_dir.join("vrc-circle.log"))
|
||||
}
|
||||
|
||||
pub fn read_logs() -> Result<Vec<LogEntry>, String> {
|
||||
let log_path = Self::get_log_file_path()?;
|
||||
|
||||
if !log_path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let content =
|
||||
fs::read_to_string(&log_path).map_err(|e| format!("Failed to read log file: {}", e))?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse log line format: "2024-01-01 12:00:00 [INFO] module: message"
|
||||
if let Some(entry) = Self::parse_log_line(line) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn parse_log_line(line: &str) -> Option<LogEntry> {
|
||||
// tauri-plugin-log format
|
||||
// Example: "2024-01-01 12:00:00.123 [INFO] vrc_circle: message"
|
||||
|
||||
let parts: Vec<&str> = line.splitn(4, ' ').collect();
|
||||
if parts.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let timestamp = format!("{} {}", parts[0], parts[1]);
|
||||
let level_part = parts[2].trim_matches(|c| c == '[' || c == ']');
|
||||
let rest = parts[3];
|
||||
|
||||
// Split module and message by ": "
|
||||
let (module, message) = if let Some(idx) = rest.find(": ") {
|
||||
let (mod_part, msg_part) = rest.split_at(idx);
|
||||
(mod_part.to_string(), msg_part[2..].to_string())
|
||||
} else {
|
||||
("unknown".to_string(), rest.to_string())
|
||||
};
|
||||
|
||||
Some(LogEntry {
|
||||
timestamp,
|
||||
level: level_part.to_uppercase(),
|
||||
source: "backend".to_string(),
|
||||
module,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_logs() -> Result<(), String> {
|
||||
let log_path = Self::get_log_file_path()?;
|
||||
|
||||
if log_path.exists() {
|
||||
fs::remove_file(&log_path).map_err(|e| format!("Failed to clear log file: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn export_logs() -> Result<String, String> {
|
||||
let entries = Self::read_logs()?;
|
||||
serde_json::to_string_pretty(&entries)
|
||||
.map_err(|e| format!("Failed to serialize logs: {}", e))
|
||||
}
|
||||
}
|
||||
17
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use std::env;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
if args.len() > 1 && args[1] == "--generate-bindings" {
|
||||
eprintln!("Generating TypeScript bindings...");
|
||||
vrc_one_lib::generate_bindings();
|
||||
eprintln!("Bindings generated successfully!");
|
||||
return;
|
||||
}
|
||||
|
||||
vrc_one_lib::run()
|
||||
}
|
||||
272
src-tauri/src/store/account_store.rs
Normal file
@@ -0,0 +1,272 @@
|
||||
use sea_orm::sea_query::{Expr, OnConflict};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, DatabaseConnection,
|
||||
EntityTrait, QueryFilter, QueryOrder, Schema, Statement, TransactionTrait,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct StoredAccount {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub avatar_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub avatar_fallback_url: Option<String>,
|
||||
pub auth_cookie: Option<String>,
|
||||
pub two_factor_cookie: Option<String>,
|
||||
pub last_login: String,
|
||||
}
|
||||
|
||||
mod account_entity {
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::ActiveModelBehavior;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "accounts")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub avatar_fallback_url: Option<String>,
|
||||
pub auth_cookie: Option<String>,
|
||||
pub two_factor_cookie: Option<String>,
|
||||
pub last_login: String,
|
||||
#[sea_orm(column_type = "Boolean", default_value = 0)]
|
||||
pub last_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
use account_entity::{
|
||||
ActiveModel as AccountActiveModel, Column as AccountColumn, Entity as AccountEntity,
|
||||
Model as AccountModel,
|
||||
};
|
||||
|
||||
pub struct AccountStore {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl AccountStore {
|
||||
pub async fn new() -> Result<Self, String> {
|
||||
let db = crate::store::connect_db("accounts").await?;
|
||||
|
||||
let store = Self { db };
|
||||
store.init_schema().await?;
|
||||
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
async fn init_schema(&self) -> Result<(), String> {
|
||||
let backend = self.db.get_database_backend();
|
||||
let schema = Schema::new(backend);
|
||||
|
||||
let create_table = schema
|
||||
.create_table_from_entity(AccountEntity)
|
||||
.if_not_exists()
|
||||
.to_owned();
|
||||
|
||||
let statement: Statement = backend.build(&create_table);
|
||||
self.db
|
||||
.execute(statement)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to initialize accounts table: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn save_account(&self, account: StoredAccount) -> Result<(), String> {
|
||||
let mut txn = self
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to begin save transaction: {}", e))?;
|
||||
|
||||
AccountEntity::update_many()
|
||||
.col_expr(AccountColumn::LastActive, Expr::value(false))
|
||||
.exec(&mut txn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to clear last active flag: {}", e))?;
|
||||
|
||||
let mut active_model = to_active_model(account);
|
||||
active_model.last_active = Set(true);
|
||||
|
||||
AccountEntity::insert(active_model)
|
||||
.on_conflict(
|
||||
OnConflict::column(AccountColumn::UserId)
|
||||
.update_columns([
|
||||
AccountColumn::Username,
|
||||
AccountColumn::DisplayName,
|
||||
AccountColumn::AvatarUrl,
|
||||
AccountColumn::AvatarFallbackUrl,
|
||||
AccountColumn::AuthCookie,
|
||||
AccountColumn::TwoFactorCookie,
|
||||
AccountColumn::LastLogin,
|
||||
AccountColumn::LastActive,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&mut txn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upsert account: {}", e))?;
|
||||
|
||||
txn.commit()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to commit account save: {}", e))
|
||||
}
|
||||
|
||||
pub async fn get_account(&self, user_id: &str) -> Result<Option<StoredAccount>, String> {
|
||||
let account = AccountEntity::find_by_id(user_id.to_string())
|
||||
.one(&self.db)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load account: {}", e))?;
|
||||
|
||||
Ok(account.map(StoredAccount::from))
|
||||
}
|
||||
|
||||
pub async fn get_last_active_account(&self) -> Result<Option<StoredAccount>, String> {
|
||||
let account = AccountEntity::find()
|
||||
.filter(AccountColumn::LastActive.eq(true))
|
||||
.one(&self.db)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load last active account: {}", e))?;
|
||||
|
||||
Ok(account.map(StoredAccount::from))
|
||||
}
|
||||
|
||||
pub async fn get_all_accounts(&self) -> Result<Vec<StoredAccount>, String> {
|
||||
let accounts = AccountEntity::find()
|
||||
.order_by_desc(AccountColumn::LastLogin)
|
||||
.all(&self.db)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load accounts: {}", e))?;
|
||||
|
||||
Ok(accounts.into_iter().map(StoredAccount::from).collect())
|
||||
}
|
||||
|
||||
pub async fn remove_account(&self, user_id: &str) -> Result<(), String> {
|
||||
let mut txn = self
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to begin remove transaction: {}", e))?;
|
||||
|
||||
let target = AccountEntity::find_by_id(user_id.to_string())
|
||||
.one(&mut txn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to find account: {}", e))?;
|
||||
|
||||
if target.is_none() {
|
||||
txn.rollback().await.ok();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let was_active = target.as_ref().map(|acc| acc.last_active).unwrap_or(false);
|
||||
|
||||
AccountEntity::delete_by_id(user_id.to_string())
|
||||
.exec(&mut txn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete account: {}", e))?;
|
||||
|
||||
if was_active {
|
||||
AccountEntity::update_many()
|
||||
.col_expr(AccountColumn::LastActive, Expr::value(false))
|
||||
.exec(&mut txn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to clear last active flag: {}", e))?;
|
||||
}
|
||||
|
||||
txn.commit()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to commit account removal: {}", e))
|
||||
}
|
||||
|
||||
pub async fn set_active_account(&self, user_id: &str) -> Result<(), String> {
|
||||
let mut txn = self
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to begin activation transaction: {}", e))?;
|
||||
|
||||
let account = AccountEntity::find_by_id(user_id.to_string())
|
||||
.one(&mut txn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to find account: {}", e))?;
|
||||
|
||||
let Some(model) = account else {
|
||||
txn.rollback().await.ok();
|
||||
return Err("Account not found".to_string());
|
||||
};
|
||||
|
||||
AccountEntity::update_many()
|
||||
.col_expr(AccountColumn::LastActive, Expr::value(false))
|
||||
.exec(&mut txn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to clear last active flags: {}", e))?;
|
||||
|
||||
let mut active_model: AccountActiveModel = model.into();
|
||||
active_model.last_active = Set(true);
|
||||
active_model
|
||||
.update(&mut txn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to mark account active: {}", e))?;
|
||||
|
||||
txn.commit()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to commit account activation: {}", e))
|
||||
}
|
||||
|
||||
pub async fn clear_all_accounts(&self) -> Result<(), String> {
|
||||
AccountEntity::delete_many()
|
||||
.exec(&self.db)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to clear accounts: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_last_active_account(&self) -> Result<(), String> {
|
||||
AccountEntity::update_many()
|
||||
.col_expr(AccountColumn::LastActive, Expr::value(false))
|
||||
.exec(&self.db)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to clear last active account: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn to_active_model(account: StoredAccount) -> AccountActiveModel {
|
||||
AccountActiveModel {
|
||||
user_id: Set(account.user_id),
|
||||
username: Set(account.username),
|
||||
display_name: Set(account.display_name),
|
||||
avatar_url: Set(account.avatar_url),
|
||||
avatar_fallback_url: Set(account.avatar_fallback_url),
|
||||
auth_cookie: Set(account.auth_cookie),
|
||||
two_factor_cookie: Set(account.two_factor_cookie),
|
||||
last_login: Set(account.last_login),
|
||||
last_active: Set(false),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AccountModel> for StoredAccount {
|
||||
fn from(model: AccountModel) -> Self {
|
||||
Self {
|
||||
user_id: model.user_id,
|
||||
username: model.username,
|
||||
display_name: model.display_name,
|
||||
avatar_url: model.avatar_url,
|
||||
avatar_fallback_url: model.avatar_fallback_url,
|
||||
auth_cookie: model.auth_cookie,
|
||||
two_factor_cookie: model.two_factor_cookie,
|
||||
last_login: model.last_login,
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src-tauri/src/store/db.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
||||
|
||||
pub async fn connect_db(component: &str) -> Result<DatabaseConnection, String> {
|
||||
// Use per-user local data directory (this is %LOCALAPPDATA% on Windows)
|
||||
let base_dir = dirs::data_local_dir()
|
||||
.ok_or("Failed to resolve local data directory")?
|
||||
.join("vrc-circle");
|
||||
|
||||
std::fs::create_dir_all(&base_dir)
|
||||
.map_err(|e| format!("Failed to create app data directory: {}", e))?;
|
||||
|
||||
let db_path = base_dir.join("data.sqlite");
|
||||
let db_url = format!(
|
||||
"sqlite://{}?mode=rwc",
|
||||
db_path.to_string_lossy().replace('\\', "/")
|
||||
);
|
||||
|
||||
let mut options = ConnectOptions::new(db_url);
|
||||
options.sqlx_logging(false);
|
||||
|
||||
Database::connect(options)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect to {} database: {}", component, e))
|
||||
}
|
||||
328
src-tauri/src/store/image_cache.rs
Normal file
@@ -0,0 +1,328 @@
|
||||
use crate::http_common::{
|
||||
INITIAL_BACKOFF as INITIAL_BACKOFF_MS, MAX_BACKOFF as MAX_BACKOFF_MS, MAX_DOWNLOADS_PER_SECOND,
|
||||
MAX_REQUEST_RETRIES, USER_AGENT_STRING,
|
||||
};
|
||||
use reqwest::{
|
||||
Client,
|
||||
header::{COOKIE, USER_AGENT},
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
|
||||
const INITIAL_BACKOFF: Duration = Duration::from_millis(INITIAL_BACKOFF_MS);
|
||||
const MAX_BACKOFF: Duration = Duration::from_millis(MAX_BACKOFF_MS);
|
||||
|
||||
pub struct ImageCacheStore {
|
||||
base_dir: PathBuf,
|
||||
client: Client,
|
||||
in_flight: Arc<Mutex<HashSet<String>>>,
|
||||
rate_limiter: Arc<Mutex<RateLimiter>>,
|
||||
}
|
||||
|
||||
struct RateLimiter {
|
||||
last_reset: Instant,
|
||||
tokens: u32,
|
||||
max_tokens: u32,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
fn new(max_tokens: u32) -> Self {
|
||||
Self {
|
||||
last_reset: Instant::now(),
|
||||
tokens: max_tokens,
|
||||
max_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
async fn acquire(&mut self) {
|
||||
loop {
|
||||
// Refill tokens if duration has passed
|
||||
let now = Instant::now();
|
||||
if now.duration_since(self.last_reset) >= Duration::from_secs(1) {
|
||||
self.tokens = self.max_tokens;
|
||||
self.last_reset = now;
|
||||
}
|
||||
|
||||
// If we have tokens, consume one and return
|
||||
if self.tokens > 0 {
|
||||
self.tokens -= 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, sleep until the next refill
|
||||
let time_until_refill =
|
||||
Duration::from_secs(1).saturating_sub(now.duration_since(self.last_reset));
|
||||
if time_until_refill > Duration::from_millis(0) {
|
||||
sleep(time_until_refill).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageCacheStore {
|
||||
pub async fn new() -> Result<Self, String> {
|
||||
// Use per-user local data directory (this is %LOCALAPPDATA% on Windows)
|
||||
let base_dir = dirs::data_local_dir()
|
||||
.ok_or("Failed to resolve local data directory")?
|
||||
.join("vrc-circle");
|
||||
|
||||
let cache_dir = base_dir.join("cache").join("files");
|
||||
|
||||
log::info!("Image cache directory: {}", cache_dir.display());
|
||||
|
||||
fs::create_dir_all(&cache_dir)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create cache HTTP client: {}", e))?;
|
||||
|
||||
Ok(Self {
|
||||
base_dir: cache_dir,
|
||||
client,
|
||||
in_flight: Arc::new(Mutex::new(HashSet::new())),
|
||||
rate_limiter: Arc::new(Mutex::new(RateLimiter::new(MAX_DOWNLOADS_PER_SECOND))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_cache_dir(&self) -> &Path {
|
||||
&self.base_dir
|
||||
}
|
||||
|
||||
pub async fn get_cached_path(&self, url: &str) -> Option<PathBuf> {
|
||||
if url.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (file_name, extension) = Self::hash_filename(url);
|
||||
let mut file_path = self.base_dir.join(&file_name);
|
||||
if let Some(ext) = extension {
|
||||
file_path.set_extension(ext);
|
||||
}
|
||||
|
||||
if fs::metadata(&file_path).await.is_ok() {
|
||||
Some(file_path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_or_fetch(
|
||||
&self,
|
||||
url: &str,
|
||||
auth_cookies: Option<String>,
|
||||
) -> Result<PathBuf, String> {
|
||||
log::info!("[CACHE] get_or_fetch called for: {}", url);
|
||||
|
||||
if url.trim().is_empty() {
|
||||
return Err("Image URL is empty".to_string());
|
||||
}
|
||||
|
||||
let (file_name, extension) = Self::hash_filename(url);
|
||||
let mut file_path = self.base_dir.join(&file_name);
|
||||
if let Some(ext) = extension {
|
||||
file_path.set_extension(ext);
|
||||
}
|
||||
|
||||
log::info!("[CACHE] Computed file path: {}", file_path.display());
|
||||
|
||||
// If cache hit
|
||||
if fs::metadata(&file_path).await.is_ok() {
|
||||
log::info!("[CACHE] HIT: {}", file_path.display());
|
||||
return Ok(file_path);
|
||||
}
|
||||
|
||||
// Rate limiter, wait for a token before acquiring the in-flight lock
|
||||
{
|
||||
let mut limiter = self.rate_limiter.lock().await;
|
||||
limiter.acquire().await;
|
||||
}
|
||||
|
||||
// Deduplicate, if another download is in progress for this URL, wait for it to complete
|
||||
{
|
||||
let mut in_flight = self.in_flight.lock().await;
|
||||
if in_flight.contains(url) {
|
||||
drop(in_flight);
|
||||
log::debug!("Download already in progress for: {}, waiting...", url);
|
||||
|
||||
// Poll for status
|
||||
for _ in 0..60 {
|
||||
// Wait up to 30 seconds (60 * 500ms)
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
if fs::metadata(&file_path).await.is_ok() {
|
||||
log::debug!(
|
||||
"Download completed by another request: {}",
|
||||
file_path.display()
|
||||
);
|
||||
return Ok(file_path);
|
||||
}
|
||||
}
|
||||
return Err(format!("Timeout waiting for concurrent download: {}", url));
|
||||
}
|
||||
|
||||
// Mark this URL as in-flight
|
||||
in_flight.insert(url.to_string());
|
||||
}
|
||||
|
||||
log::info!("Cache MISS: Downloading {}", url);
|
||||
let result = self.do_download(url, auth_cookies, &file_path).await;
|
||||
|
||||
// Remove from in-flight set
|
||||
{
|
||||
let mut in_flight = self.in_flight.lock().await;
|
||||
in_flight.remove(url);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn do_download(
|
||||
&self,
|
||||
url: &str,
|
||||
auth_cookies: Option<String>,
|
||||
file_path: &Path,
|
||||
) -> Result<PathBuf, String> {
|
||||
let tmp_path = file_path.with_extension("tmp");
|
||||
|
||||
if let Some(parent) = tmp_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to prepare cache directory: {}", e))?;
|
||||
}
|
||||
|
||||
let bytes = self.download_with_retry(url, auth_cookies).await?;
|
||||
log::info!("Downloaded {} bytes from {}", bytes.len(), url);
|
||||
|
||||
let mut file = fs::File::create(&tmp_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create cache file: {}", e))?;
|
||||
file.write_all(&bytes)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write cache file: {}", e))?;
|
||||
file.flush()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to flush cache file: {}", e))?;
|
||||
|
||||
fs::rename(&tmp_path, &file_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to finalize cache file: {}", e))?;
|
||||
|
||||
log::info!("Cached to: {}", file_path.display());
|
||||
|
||||
Ok(file_path.to_path_buf())
|
||||
}
|
||||
|
||||
fn hash_filename(url: &str) -> (String, Option<String>) {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(url.as_bytes());
|
||||
let hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
let extension = url::Url::parse(url).ok().and_then(|parsed| {
|
||||
Path::new(parsed.path())
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.split('?').next().unwrap_or(name))
|
||||
.and_then(|name| Path::new(name).extension())
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_lowercase())
|
||||
});
|
||||
|
||||
(hash, extension)
|
||||
}
|
||||
|
||||
async fn download_with_retry(
|
||||
&self,
|
||||
url: &str,
|
||||
auth_cookies: Option<String>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut attempt = 0;
|
||||
let mut backoff = INITIAL_BACKOFF;
|
||||
|
||||
loop {
|
||||
let mut request = self.client.get(url).header(USER_AGENT, USER_AGENT_STRING);
|
||||
|
||||
// Only pass cookies if URL is from VRChat domains to prevent leaking credentials
|
||||
let is_vrchat_domain = url.starts_with("https://api.vrchat.cloud/")
|
||||
|| url.starts_with("https://files.vrchat.cloud/")
|
||||
|| url.starts_with("https://assets.vrchat.com/")
|
||||
|| url.starts_with("https://d348imysud55la.cloudfront.net/");
|
||||
|
||||
if is_vrchat_domain {
|
||||
if let Some(ref cookies) = auth_cookies {
|
||||
request = request.header(COOKIE, cookies);
|
||||
}
|
||||
}
|
||||
|
||||
let response = request.send().await;
|
||||
match response {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
log::debug!("[CACHE] HTTP {}: {}", status.as_u16(), url);
|
||||
|
||||
if status.is_success() {
|
||||
return resp.bytes().await.map(|bytes| bytes.to_vec()).map_err(|e| {
|
||||
log::error!("[CACHE] Failed to read bytes: {}", e);
|
||||
format!("Failed to read image bytes: {}", e)
|
||||
});
|
||||
}
|
||||
|
||||
if status.as_u16() == 429 || status.is_server_error() {
|
||||
log::warn!("[CACHE] Retryable error {} for: {}", status.as_u16(), url);
|
||||
if attempt >= MAX_REQUEST_RETRIES {
|
||||
return Err(format!(
|
||||
"Image request failed after retries (status {}): {}",
|
||||
status.as_u16(),
|
||||
url
|
||||
));
|
||||
}
|
||||
|
||||
let wait = Self::retry_after_seconds(&resp).unwrap_or(backoff);
|
||||
sleep(wait).await;
|
||||
attempt += 1;
|
||||
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||
continue;
|
||||
}
|
||||
|
||||
log::error!("[CACHE] HTTP error {}: {}", status.as_u16(), url);
|
||||
return Err(format!(
|
||||
"Failed to download image (status {}): {}",
|
||||
status.as_u16(),
|
||||
url
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"[CACHE] Network error: {} (attempt {}/{})",
|
||||
err,
|
||||
attempt + 1,
|
||||
MAX_REQUEST_RETRIES
|
||||
);
|
||||
if attempt >= MAX_REQUEST_RETRIES {
|
||||
return Err(format!("Failed to download image: {}", err));
|
||||
}
|
||||
sleep(backoff).await;
|
||||
attempt += 1;
|
||||
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_after_seconds(response: &reqwest::Response) -> Option<Duration> {
|
||||
response
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|seconds| seconds.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
}
|
||||
}
|
||||
11
src-tauri/src/store/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod account_store;
|
||||
pub mod image_cache;
|
||||
pub mod settings_store;
|
||||
pub mod user_store;
|
||||
pub mod db;
|
||||
|
||||
pub use account_store::{AccountStore, StoredAccount};
|
||||
pub use image_cache::ImageCacheStore;
|
||||
pub use settings_store::{AppSettings, SettingsStore};
|
||||
pub use user_store::UserStore;
|
||||
pub use db::connect_db;
|
||||
135
src-tauri/src/store/settings_store.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use sea_orm::sea_query::OnConflict;
|
||||
use sea_orm::{
|
||||
ActiveValue::Set, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter,
|
||||
Schema, Statement,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct AppSettings {
|
||||
pub developer_mode: bool,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
developer_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod settings_entity {
|
||||
use sea_orm::ActiveModelBehavior;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "settings")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
use settings_entity::{
|
||||
ActiveModel as SettingsActiveModel, Column as SettingsColumn, Entity as SettingsEntity,
|
||||
};
|
||||
|
||||
pub struct SettingsStore {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl SettingsStore {
|
||||
pub async fn new() -> Result<Self, String> {
|
||||
let db = crate::store::connect_db("settings").await?;
|
||||
let store = Self { db };
|
||||
store.init_schema().await?;
|
||||
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
async fn init_schema(&self) -> Result<(), String> {
|
||||
let backend = self.db.get_database_backend();
|
||||
let schema = Schema::new(backend);
|
||||
|
||||
let create_table = schema
|
||||
.create_table_from_entity(SettingsEntity)
|
||||
.if_not_exists()
|
||||
.to_owned();
|
||||
|
||||
let statement: Statement = backend.build(&create_table);
|
||||
self.db
|
||||
.execute(statement)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to initialize settings table: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_setting(&self, key: &str, default: &str) -> Result<String, String> {
|
||||
let settings_row = SettingsEntity::find()
|
||||
.filter(SettingsColumn::Key.eq(key))
|
||||
.one(&self.db)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load setting '{}': {}", key, e))?;
|
||||
|
||||
Ok(settings_row
|
||||
.map(|row| row.value)
|
||||
.unwrap_or_else(|| default.to_string()))
|
||||
}
|
||||
|
||||
async fn set_setting(&self, key: &str, value: &str) -> Result<(), String> {
|
||||
let active_model = SettingsActiveModel {
|
||||
key: Set(key.to_string()),
|
||||
value: Set(value.to_string()),
|
||||
};
|
||||
|
||||
SettingsEntity::insert(active_model)
|
||||
.on_conflict(
|
||||
OnConflict::column(SettingsColumn::Key)
|
||||
.update_column(SettingsColumn::Value)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&self.db)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save setting '{}': {}", key, e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_settings(&self) -> Result<AppSettings, String> {
|
||||
let developer_mode = self.get_setting("developer_mode", "false").await?;
|
||||
|
||||
Ok(AppSettings {
|
||||
developer_mode: developer_mode == "true",
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn save_settings(&self, settings: AppSettings) -> Result<(), String> {
|
||||
self.set_setting(
|
||||
"developer_mode",
|
||||
if settings.developer_mode {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_developer_mode(&self) -> Result<bool, String> {
|
||||
let value = self.get_setting("developer_mode", "false").await?;
|
||||
Ok(value == "true")
|
||||
}
|
||||
|
||||
pub async fn set_developer_mode(&self, enabled: bool) -> Result<(), String> {
|
||||
self.set_setting("developer_mode", if enabled { "true" } else { "false" })
|
||||
.await
|
||||
}
|
||||
}
|
||||
651
src-tauri/src/store/user_store.rs
Normal file
@@ -0,0 +1,651 @@
|
||||
use crate::vrchat_api::types::{LimitedUserFriend, User, UserStatus};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UserRelationship {
|
||||
CurrentUser,
|
||||
Friend,
|
||||
Known,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn parse_user_status(status: &str) -> UserStatus {
|
||||
match status {
|
||||
"active" => UserStatus::Active,
|
||||
"join me" => UserStatus::JoinMe,
|
||||
"ask me" => UserStatus::AskMe,
|
||||
"busy" => UserStatus::Busy,
|
||||
_ => UserStatus::Offline, // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Platform {
|
||||
StandaloneWindows,
|
||||
Android,
|
||||
Web,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
fn parse_platform(platform: &str) -> Platform {
|
||||
match platform {
|
||||
"standalonewindows" => Platform::StandaloneWindows,
|
||||
"android" => Platform::Android,
|
||||
"web" => Platform::Web,
|
||||
other => Platform::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached user representation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CachedUser {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub username: Option<String>,
|
||||
pub user_icon: Option<String>,
|
||||
pub profile_pic_override: Option<String>,
|
||||
pub profile_pic_override_thumbnail: Option<String>,
|
||||
pub current_avatar_image_url: Option<String>,
|
||||
pub current_avatar_thumbnail_image_url: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub status: Option<UserStatus>,
|
||||
pub status_description: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub platform: Option<Platform>,
|
||||
pub relationship: UserRelationship,
|
||||
pub full_user: Option<User>,
|
||||
pub friend_data: Option<LimitedUserFriend>,
|
||||
pub last_updated: std::time::Instant,
|
||||
}
|
||||
|
||||
impl CachedUser {
|
||||
/// Create from LimitedUserFriend (friend list entry)
|
||||
pub fn from_friend(friend: LimitedUserFriend) -> Self {
|
||||
Self {
|
||||
id: friend.id.clone(),
|
||||
display_name: friend.display_name.clone(),
|
||||
username: None,
|
||||
user_icon: friend.user_icon.clone(),
|
||||
profile_pic_override: friend.profile_pic_override.clone(),
|
||||
profile_pic_override_thumbnail: friend.profile_pic_override_thumbnail.clone(),
|
||||
current_avatar_image_url: friend.current_avatar_image_url.clone(),
|
||||
current_avatar_thumbnail_image_url: friend.current_avatar_thumbnail_image_url.clone(),
|
||||
bio: Some(friend.bio.clone()),
|
||||
status: Some(friend.status.clone()),
|
||||
status_description: Some(friend.status_description.clone()),
|
||||
location: friend.location.clone(),
|
||||
platform: Some(parse_platform(&friend.platform)),
|
||||
relationship: UserRelationship::Friend,
|
||||
full_user: None,
|
||||
friend_data: Some(friend),
|
||||
last_updated: std::time::Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from User (full user object)
|
||||
pub fn from_user(user: User, relationship: UserRelationship) -> Self {
|
||||
Self {
|
||||
id: user.id.clone(),
|
||||
display_name: user.display_name.clone(),
|
||||
username: if user.username.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(user.username.clone())
|
||||
},
|
||||
user_icon: user.user_icon.clone(),
|
||||
profile_pic_override: user.profile_pic_override.clone(),
|
||||
profile_pic_override_thumbnail: user.profile_pic_override_thumbnail.clone(),
|
||||
current_avatar_image_url: user.current_avatar_image_url.clone(),
|
||||
current_avatar_thumbnail_image_url: user.current_avatar_thumbnail_image_url.clone(),
|
||||
bio: Some(user.bio.clone()),
|
||||
status: Some(user.status.clone()),
|
||||
status_description: Some(user.status_description.clone()),
|
||||
location: user.location.clone(),
|
||||
platform: Some(parse_platform(&user.platform)),
|
||||
relationship,
|
||||
full_user: Some(user),
|
||||
friend_data: None,
|
||||
last_updated: std::time::Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update from friend data
|
||||
pub fn update_from_friend(&mut self, friend: LimitedUserFriend) {
|
||||
self.display_name = friend.display_name.clone();
|
||||
self.user_icon = friend.user_icon.clone();
|
||||
self.profile_pic_override = friend.profile_pic_override.clone();
|
||||
self.profile_pic_override_thumbnail = friend.profile_pic_override_thumbnail.clone();
|
||||
self.current_avatar_image_url = friend.current_avatar_image_url.clone();
|
||||
self.current_avatar_thumbnail_image_url = friend.current_avatar_thumbnail_image_url.clone();
|
||||
self.bio = Some(friend.bio.clone());
|
||||
self.status = Some(friend.status.clone());
|
||||
self.status_description = Some(friend.status_description.clone());
|
||||
self.location = friend.location.clone();
|
||||
self.platform = Some(parse_platform(&friend.platform));
|
||||
self.friend_data = Some(friend);
|
||||
self.last_updated = std::time::Instant::now();
|
||||
}
|
||||
|
||||
pub fn is_online(&self) -> bool {
|
||||
self.location.as_ref().map_or(false, |loc| {
|
||||
loc != "offline" && loc != "private" && !loc.is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
/// Get as LimitedUserFriend if available
|
||||
pub fn as_friend(&self) -> Option<&LimitedUserFriend> {
|
||||
self.friend_data.as_ref()
|
||||
}
|
||||
|
||||
pub fn age_seconds(&self) -> u64 {
|
||||
self.last_updated.elapsed().as_secs()
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal representation of a current-user update emitted by the pipeline websocket.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CurrentUserPipelineUpdate {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub username: String,
|
||||
pub status: String,
|
||||
pub status_description: String,
|
||||
pub bio: String,
|
||||
pub user_icon: Option<String>,
|
||||
pub profile_pic_override: Option<String>,
|
||||
pub profile_pic_override_thumbnail: Option<String>,
|
||||
pub current_avatar: Option<String>,
|
||||
pub current_avatar_asset_url: Option<String>,
|
||||
pub current_avatar_image_url: Option<String>,
|
||||
pub current_avatar_thumbnail_image_url: Option<String>,
|
||||
pub fallback_avatar: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UserStore {
|
||||
users: Arc<RwLock<HashMap<String, CachedUser>>>,
|
||||
current_user_id: Arc<RwLock<Option<String>>>,
|
||||
}
|
||||
|
||||
impl UserStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
users: Arc::new(RwLock::new(HashMap::new())),
|
||||
current_user_id: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Current User Management
|
||||
pub async fn set_current_user(&self, user: User) {
|
||||
let user_id = user.id.clone();
|
||||
let cached_user = CachedUser::from_user(user, UserRelationship::CurrentUser);
|
||||
|
||||
let mut users = self.users.write().await;
|
||||
users.insert(user_id.clone(), cached_user);
|
||||
drop(users);
|
||||
|
||||
let mut current = self.current_user_id.write().await;
|
||||
*current = Some(user_id.clone());
|
||||
|
||||
log::info!("UserStore: Set current user to {}", user_id);
|
||||
}
|
||||
|
||||
pub async fn get_current_user(&self) -> Option<User> {
|
||||
let current_id = self.current_user_id.read().await;
|
||||
let user_id = current_id.as_ref()?.clone();
|
||||
drop(current_id);
|
||||
|
||||
let users = self.users.read().await;
|
||||
users.get(&user_id)?.full_user.clone()
|
||||
}
|
||||
|
||||
pub async fn get_current_user_id(&self) -> Option<String> {
|
||||
let current = self.current_user_id.read().await;
|
||||
current.clone()
|
||||
}
|
||||
|
||||
pub async fn clear_current_user(&self) {
|
||||
let mut current = self.current_user_id.write().await;
|
||||
*current = None;
|
||||
log::info!("UserStore: Cleared current user");
|
||||
}
|
||||
|
||||
// Friend Management
|
||||
|
||||
// TODO: Refactor this code and function name
|
||||
/// Initialize friends list (from REST API on startup)
|
||||
pub async fn set_friends(&self, friends: Vec<LimitedUserFriend>) {
|
||||
let mut users = self.users.write().await;
|
||||
|
||||
// Mark all existing friends as non-friends first
|
||||
for user in users.values_mut() {
|
||||
if user.relationship == UserRelationship::Friend {
|
||||
user.relationship = UserRelationship::Known;
|
||||
}
|
||||
}
|
||||
|
||||
// Add/update friends
|
||||
for friend in friends {
|
||||
let user_id = friend.id.clone();
|
||||
|
||||
if let Some(existing) = users.get_mut(&user_id) {
|
||||
existing.update_from_friend(friend);
|
||||
existing.relationship = UserRelationship::Friend;
|
||||
} else {
|
||||
users.insert(user_id, CachedUser::from_friend(friend));
|
||||
}
|
||||
}
|
||||
|
||||
let friend_count = users
|
||||
.values()
|
||||
.filter(|u| u.relationship == UserRelationship::Friend)
|
||||
.count();
|
||||
|
||||
log::info!("UserStore: Initialized {} friends", friend_count);
|
||||
}
|
||||
|
||||
/// Upsert friend
|
||||
pub async fn upsert_friend(&self, friend: LimitedUserFriend) {
|
||||
let user_id = friend.id.clone();
|
||||
|
||||
// Check if this is the current user
|
||||
// Of course, don't add yourself to friends list here
|
||||
let current_id = self.current_user_id.read().await;
|
||||
if let Some(ref current) = *current_id {
|
||||
if current == &user_id {
|
||||
log::debug!(
|
||||
"UserStore: Skipping upsert_friend for current user {}",
|
||||
user_id
|
||||
);
|
||||
drop(current_id);
|
||||
|
||||
// But still update other data if available
|
||||
let mut users = self.users.write().await;
|
||||
if let Some(existing) = users.get_mut(&user_id) {
|
||||
if let Some(location) = friend.location.clone() {
|
||||
existing.location = Some(location.clone());
|
||||
|
||||
if let Some(full_user) = existing.full_user.as_mut() {
|
||||
full_user.location = Some(location);
|
||||
}
|
||||
}
|
||||
|
||||
if !friend.platform.is_empty() {
|
||||
existing.platform = Some(parse_platform(&friend.platform));
|
||||
|
||||
if let Some(full_user) = existing.full_user.as_mut() {
|
||||
full_user.platform = friend.platform.clone();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
existing.status = Some(friend.status.clone());
|
||||
|
||||
if let Some(full_user) = existing.full_user.as_mut() {
|
||||
full_user.status = friend.status;
|
||||
}
|
||||
}
|
||||
|
||||
if !friend.status_description.is_empty() {
|
||||
existing.status_description = Some(friend.status_description.clone());
|
||||
|
||||
if let Some(full_user) = existing.full_user.as_mut() {
|
||||
full_user.status_description = friend.status_description.clone();
|
||||
}
|
||||
}
|
||||
|
||||
existing.friend_data = Some(friend);
|
||||
existing.last_updated = std::time::Instant::now();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
drop(current_id);
|
||||
|
||||
let mut users = self.users.write().await;
|
||||
|
||||
if let Some(existing) = users.get_mut(&user_id) {
|
||||
existing.update_from_friend(friend);
|
||||
existing.relationship = UserRelationship::Friend;
|
||||
} else {
|
||||
users.insert(user_id.clone(), CachedUser::from_friend(friend));
|
||||
}
|
||||
|
||||
log::debug!("UserStore: Upserted friend {}", user_id);
|
||||
}
|
||||
|
||||
/// Mark a friend as offline
|
||||
pub async fn set_friend_offline(&self, user_id: &str) {
|
||||
let mut users = self.users.write().await;
|
||||
|
||||
if let Some(user) = users.get_mut(user_id) {
|
||||
user.location = Some("offline".to_string());
|
||||
user.last_updated = std::time::Instant::now();
|
||||
|
||||
if let Some(ref mut friend_data) = user.friend_data {
|
||||
friend_data.location = Some("offline".to_string());
|
||||
friend_data.platform = String::new();
|
||||
}
|
||||
|
||||
log::debug!("UserStore: User {} went offline", user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update friend's location
|
||||
pub async fn update_user_location(
|
||||
&self,
|
||||
user_id: &str,
|
||||
location: String,
|
||||
platform: Option<String>,
|
||||
) {
|
||||
let mut users = self.users.write().await;
|
||||
|
||||
if let Some(user) = users.get_mut(user_id) {
|
||||
user.location = Some(location.clone());
|
||||
if let Some(plat) = platform {
|
||||
user.platform = Some(parse_platform(&plat));
|
||||
}
|
||||
user.last_updated = std::time::Instant::now();
|
||||
|
||||
if let Some(ref mut friend_data) = user.friend_data {
|
||||
friend_data.location = Some(location.clone());
|
||||
if let Some(ref plat) = user.platform {
|
||||
friend_data.platform = match plat {
|
||||
Platform::StandaloneWindows => "standalonewindows".to_string(),
|
||||
Platform::Android => "android".to_string(),
|
||||
Platform::Web => "web".to_string(),
|
||||
Platform::Other(s) => s.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!(
|
||||
"UserStore: User {} location updated to {}",
|
||||
user_id,
|
||||
location
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a friend when the relationship is terminated :(
|
||||
pub async fn remove_friend(&self, user_id: &str) {
|
||||
let mut users = self.users.write().await;
|
||||
|
||||
if let Some(user) = users.get_mut(user_id) {
|
||||
user.relationship = UserRelationship::Known;
|
||||
user.friend_data = None;
|
||||
user.location = None;
|
||||
user.platform = None;
|
||||
user.last_updated = std::time::Instant::now();
|
||||
|
||||
log::info!("UserStore: Removed friend {}", user_id);
|
||||
} else {
|
||||
log::debug!("UserStore: Attempted to remove unknown friend {}", user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch the cached current-user record with data streamed from the websocket.
|
||||
pub async fn apply_current_user_update(&self, patch: CurrentUserPipelineUpdate) {
|
||||
use std::time::Instant;
|
||||
|
||||
{
|
||||
let mut users = self.users.write().await;
|
||||
let entry = users.entry(patch.id.clone()).or_insert_with(|| CachedUser {
|
||||
id: patch.id.clone(),
|
||||
display_name: patch.display_name.clone(),
|
||||
username: Some(patch.username.clone()),
|
||||
user_icon: patch.user_icon.clone(),
|
||||
profile_pic_override: patch.profile_pic_override.clone(),
|
||||
profile_pic_override_thumbnail: patch.profile_pic_override_thumbnail.clone(),
|
||||
current_avatar_image_url: patch.current_avatar_image_url.clone(),
|
||||
current_avatar_thumbnail_image_url: patch
|
||||
.current_avatar_thumbnail_image_url
|
||||
.clone(),
|
||||
bio: Some(patch.bio.clone()),
|
||||
status: Some(parse_user_status(&patch.status)),
|
||||
status_description: Some(patch.status_description.clone()),
|
||||
location: None,
|
||||
platform: None,
|
||||
relationship: UserRelationship::CurrentUser,
|
||||
full_user: None,
|
||||
friend_data: None,
|
||||
last_updated: Instant::now(),
|
||||
});
|
||||
|
||||
entry.display_name = patch.display_name.clone();
|
||||
entry.username = Some(patch.username.clone());
|
||||
entry.user_icon = patch.user_icon.clone();
|
||||
entry.profile_pic_override = patch.profile_pic_override.clone();
|
||||
entry.profile_pic_override_thumbnail = patch.profile_pic_override_thumbnail.clone();
|
||||
entry.current_avatar_image_url = patch.current_avatar_image_url.clone();
|
||||
entry.current_avatar_thumbnail_image_url =
|
||||
patch.current_avatar_thumbnail_image_url.clone();
|
||||
entry.bio = Some(patch.bio.clone());
|
||||
entry.status = Some(parse_user_status(&patch.status));
|
||||
entry.status_description = Some(patch.status_description.clone());
|
||||
entry.relationship = UserRelationship::CurrentUser;
|
||||
entry.last_updated = Instant::now();
|
||||
|
||||
if let Some(full_user) = entry.full_user.as_mut() {
|
||||
full_user.display_name = patch.display_name.clone();
|
||||
full_user.username = patch.username.clone();
|
||||
full_user.status = parse_user_status(&patch.status);
|
||||
full_user.status_description = patch.status_description.clone();
|
||||
full_user.bio = patch.bio.clone();
|
||||
full_user.user_icon = patch.user_icon.clone();
|
||||
full_user.profile_pic_override = patch.profile_pic_override.clone();
|
||||
full_user.profile_pic_override_thumbnail =
|
||||
patch.profile_pic_override_thumbnail.clone();
|
||||
full_user.current_avatar = patch.current_avatar.clone();
|
||||
full_user.current_avatar_image_url = patch.current_avatar_image_url.clone();
|
||||
full_user.current_avatar_thumbnail_image_url =
|
||||
patch.current_avatar_thumbnail_image_url.clone();
|
||||
full_user.fallback_avatar = patch.fallback_avatar.clone();
|
||||
full_user.tags = patch.tags.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let mut current_id = self.current_user_id.write().await;
|
||||
*current_id = Some(patch.id);
|
||||
}
|
||||
|
||||
/// Get all friends (online and offline)
|
||||
pub async fn get_all_friends(&self) -> Vec<LimitedUserFriend> {
|
||||
let users = self.users.read().await;
|
||||
users
|
||||
.values()
|
||||
.filter(|u| u.relationship == UserRelationship::Friend)
|
||||
.filter_map(|u| u.friend_data.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all online friends
|
||||
pub async fn get_online_friends(&self) -> Vec<LimitedUserFriend> {
|
||||
let users = self.users.read().await;
|
||||
users
|
||||
.values()
|
||||
.filter(|u| u.relationship == UserRelationship::Friend)
|
||||
.filter(|u| u.is_online())
|
||||
.filter_map(|u| u.friend_data.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get count of online friends
|
||||
pub async fn get_online_friend_count(&self) -> usize {
|
||||
let users = self.users.read().await;
|
||||
users
|
||||
.values()
|
||||
.filter(|u| u.relationship == UserRelationship::Friend)
|
||||
.filter(|u| u.is_online())
|
||||
.count()
|
||||
}
|
||||
|
||||
// General User Queries
|
||||
|
||||
/// Get a user by ID (returns friend data if they're a friend)
|
||||
pub async fn get_user(&self, user_id: &str) -> Option<LimitedUserFriend> {
|
||||
let users = self.users.read().await;
|
||||
users.get(user_id)?.friend_data.clone()
|
||||
}
|
||||
|
||||
/// Get a full User object by ID
|
||||
pub async fn get_full_user(&self, user_id: &str) -> Option<User> {
|
||||
let users = self.users.read().await;
|
||||
users.get(user_id)?.full_user.clone()
|
||||
}
|
||||
|
||||
/// Cache a full User object (for non-current users)
|
||||
pub async fn cache_full_user(&self, user: User) {
|
||||
let user_id = user.id.clone();
|
||||
let mut users = self.users.write().await;
|
||||
|
||||
if let Some(existing) = users.get_mut(&user_id) {
|
||||
// Update existing cached user with full user data
|
||||
let relationship = existing.relationship.clone();
|
||||
existing.display_name = user.display_name.clone();
|
||||
|
||||
// Only update username if it's not empty
|
||||
// You only see usernames for yourself in the API
|
||||
if !user.username.is_empty() {
|
||||
existing.username = Some(user.username.clone());
|
||||
}
|
||||
existing.user_icon = user.user_icon.clone();
|
||||
existing.profile_pic_override = user.profile_pic_override.clone();
|
||||
existing.profile_pic_override_thumbnail = user.profile_pic_override_thumbnail.clone();
|
||||
existing.current_avatar_image_url = user.current_avatar_image_url.clone();
|
||||
existing.current_avatar_thumbnail_image_url =
|
||||
user.current_avatar_thumbnail_image_url.clone();
|
||||
existing.bio = Some(user.bio.clone());
|
||||
existing.status = Some(user.status.clone());
|
||||
existing.status_description = Some(user.status_description.clone());
|
||||
existing.location = user.location.clone();
|
||||
existing.platform = Some(parse_platform(&user.platform));
|
||||
existing.full_user = Some(user);
|
||||
existing.last_updated = std::time::Instant::now();
|
||||
existing.relationship = relationship;
|
||||
} else {
|
||||
users.insert(
|
||||
user_id.clone(),
|
||||
CachedUser::from_user(user, UserRelationship::Known),
|
||||
);
|
||||
}
|
||||
|
||||
log::debug!("UserStore: Cached full user data for {}", user_id);
|
||||
}
|
||||
|
||||
/// Get cached user entry
|
||||
pub async fn get_cached_user(&self, user_id: &str) -> Option<CachedUser> {
|
||||
let users = self.users.read().await;
|
||||
users.get(user_id).cloned()
|
||||
}
|
||||
|
||||
/// Check if a user is a friend
|
||||
pub async fn is_friend(&self, user_id: &str) -> bool {
|
||||
let users = self.users.read().await;
|
||||
users
|
||||
.get(user_id)
|
||||
.map_or(false, |u| u.relationship == UserRelationship::Friend)
|
||||
}
|
||||
|
||||
/// Check if a user is online
|
||||
pub async fn is_user_online(&self, user_id: &str) -> bool {
|
||||
let users = self.users.read().await;
|
||||
users.get(user_id).map_or(false, |u| u.is_online())
|
||||
}
|
||||
|
||||
/// Search users by display name (case-insensitive, partial match)
|
||||
pub async fn search_users(&self, query: &str) -> Vec<CachedUser> {
|
||||
let users = self.users.read().await;
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
users
|
||||
.values()
|
||||
.filter(|u| u.display_name.to_lowercase().contains(&query_lower))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Cache Management
|
||||
|
||||
/// Clear all cached users (keeps current user)
|
||||
pub async fn clear_cache(&self) {
|
||||
let current_id = self.current_user_id.read().await.clone();
|
||||
let mut users = self.users.write().await;
|
||||
|
||||
if let Some(current_id) = current_id {
|
||||
users.retain(|id, _| id == ¤t_id); // Keep only current user
|
||||
} else {
|
||||
users.clear();
|
||||
}
|
||||
|
||||
log::info!("UserStore: Cleared cache");
|
||||
}
|
||||
|
||||
/// Clear all data
|
||||
pub async fn clear_all(&self) {
|
||||
let mut users = self.users.write().await;
|
||||
users.clear();
|
||||
drop(users);
|
||||
|
||||
let mut current = self.current_user_id.write().await;
|
||||
*current = None;
|
||||
|
||||
log::info!("UserStore: Cleared all data");
|
||||
}
|
||||
|
||||
/// Remove stale entries that are older than max_age_seconds
|
||||
pub async fn remove_stale(&self, max_age_seconds: u64) {
|
||||
let current_id = self.current_user_id.read().await.clone();
|
||||
let mut users = self.users.write().await;
|
||||
|
||||
let before_count = users.len();
|
||||
users.retain(|id, user| {
|
||||
// Keep current user and friends
|
||||
if Some(id) == current_id.as_ref() || user.relationship == UserRelationship::Friend {
|
||||
return true;
|
||||
}
|
||||
// Keep recent entries
|
||||
user.age_seconds() < max_age_seconds
|
||||
});
|
||||
|
||||
let removed = before_count - users.len();
|
||||
if removed > 0 {
|
||||
log::info!("UserStore: Removed {} stale entries", removed);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_stats(&self) -> UserStoreStats {
|
||||
let users = self.users.read().await;
|
||||
let current_id = self.current_user_id.read().await.clone();
|
||||
|
||||
UserStoreStats {
|
||||
total_cached: users.len(),
|
||||
friends: users
|
||||
.values()
|
||||
.filter(|u| u.relationship == UserRelationship::Friend)
|
||||
.count(),
|
||||
online_friends: users
|
||||
.values()
|
||||
.filter(|u| u.relationship == UserRelationship::Friend && u.is_online())
|
||||
.count(),
|
||||
has_current_user: current_id.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UserStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserStoreStats {
|
||||
pub total_cached: usize,
|
||||
pub friends: usize,
|
||||
pub online_friends: usize,
|
||||
pub has_current_user: bool,
|
||||
}
|
||||
710
src-tauri/src/vrchat_api/client.rs
Normal file
@@ -0,0 +1,710 @@
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::{Client, Request, RequestBuilder, Response};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
use crate::http_common::{INITIAL_BACKOFF, MAX_BACKOFF, MAX_REQUEST_RETRIES};
|
||||
use crate::vrchat_api::{
|
||||
error::{VRCError, VRCResult},
|
||||
types::*,
|
||||
};
|
||||
|
||||
const API_BASE_URL: &str = "https://api.vrchat.cloud/api/1";
|
||||
|
||||
// Cookie Management
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct CookieStore {
|
||||
auth_cookie: Option<String>,
|
||||
two_factor_cookie: Option<String>,
|
||||
}
|
||||
|
||||
impl CookieStore {
|
||||
fn to_header_value(&self) -> Option<String> {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if let Some(auth) = &self.auth_cookie {
|
||||
parts.push(auth.clone());
|
||||
}
|
||||
|
||||
if let Some(two_fa) = &self.two_factor_cookie {
|
||||
parts.push(two_fa.clone());
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
fn update_from_response(&mut self, response: &Response) {
|
||||
for cookie_header in response.headers().get_all("set-cookie") {
|
||||
if let Ok(cookie_str) = cookie_header.to_str() {
|
||||
if let Some(cookie_pair) = cookie_str.split(';').next() {
|
||||
if cookie_pair.starts_with("auth=") {
|
||||
self.auth_cookie = Some(cookie_pair.to_string());
|
||||
} else if cookie_pair.starts_with("twoFactorAuth=") {
|
||||
self.two_factor_cookie = Some(cookie_pair.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
fn has_auth(&self) -> bool {
|
||||
self.auth_cookie.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
// VRChat HTTP Client for VRChat API requests
|
||||
#[derive(Clone)]
|
||||
pub struct VRChatClient {
|
||||
http_client: Client,
|
||||
cookies: Arc<Mutex<CookieStore>>,
|
||||
}
|
||||
|
||||
impl VRChatClient {
|
||||
/// Create a new VRChat API client
|
||||
pub fn new() -> VRCResult<Self> {
|
||||
let http_client = Client::builder()
|
||||
.cookie_store(false)
|
||||
.build()
|
||||
.map_err(|e| VRCError::network(format!("Failed to create HTTP client: {}", e)))?;
|
||||
|
||||
Ok(Self {
|
||||
http_client,
|
||||
cookies: Arc::new(Mutex::new(CookieStore::default())),
|
||||
})
|
||||
}
|
||||
|
||||
// Authentication Methods
|
||||
|
||||
/// Attempt to log in with email and password
|
||||
pub async fn login(&self, credentials: &LoginCredentials) -> VRCResult<LoginResult> {
|
||||
let auth_header = Self::create_basic_auth(&credentials.email, &credentials.password);
|
||||
let headers = self.build_headers(Some(&auth_header), None, None);
|
||||
|
||||
let response = self
|
||||
.execute_request(
|
||||
self.http_client
|
||||
.get(&format!("{}/auth/user", API_BASE_URL))
|
||||
.headers(headers),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut cookies = self.cookies.lock().await;
|
||||
cookies.update_from_response(&response);
|
||||
drop(cookies);
|
||||
|
||||
let status = response.status();
|
||||
|
||||
if status == 429 {
|
||||
return Err(VRCError::rate_limit(
|
||||
"Too many requests. Please wait before trying again.",
|
||||
));
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Login failed".to_string());
|
||||
return Err(VRCError::auth(error_text));
|
||||
}
|
||||
|
||||
let body = response.text().await?;
|
||||
|
||||
// Check if 2FA is required
|
||||
if let Ok(two_fa) = serde_json::from_str::<TwoFactorAuthResponse>(&body) {
|
||||
if let Some(methods) = two_fa.requires_two_factor_auth {
|
||||
return Ok(LoginResult::TwoFactorRequired { methods });
|
||||
}
|
||||
}
|
||||
|
||||
let user: User = serde_json::from_str(&body)?;
|
||||
Ok(LoginResult::Success { user })
|
||||
}
|
||||
|
||||
/// Verify two-factor authentication code
|
||||
pub async fn verify_two_factor(&self, code: &str, method: TwoFactorMethod) -> VRCResult<bool> {
|
||||
let cookie_header = {
|
||||
let cookies = self.cookies.lock().await;
|
||||
cookies.to_header_value()
|
||||
};
|
||||
|
||||
let headers = self.build_headers(None, None, cookie_header.as_deref());
|
||||
|
||||
let request_body = TwoFactorCode {
|
||||
code: code.to_string(),
|
||||
};
|
||||
|
||||
let response = self
|
||||
.execute_request(
|
||||
self.http_client
|
||||
.post(&format!(
|
||||
"{}/auth/twofactorauth/{}/verify",
|
||||
API_BASE_URL,
|
||||
method.endpoint()
|
||||
))
|
||||
.headers(headers)
|
||||
.json(&request_body),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
let mut cookies = self.cookies.lock().await;
|
||||
cookies.update_from_response(&response);
|
||||
drop(cookies);
|
||||
|
||||
if status == 429 {
|
||||
return Err(VRCError::rate_limit(
|
||||
"Too many requests. Please wait before trying again.",
|
||||
));
|
||||
}
|
||||
|
||||
if !status.is_success() && status.as_u16() != 400 {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Verification failed".to_string());
|
||||
return Err(VRCError::auth(error_text));
|
||||
}
|
||||
|
||||
let body = response.text().await?;
|
||||
let verify_response: TwoFactorVerifyResponse = serde_json::from_str(&body)?;
|
||||
|
||||
Ok(verify_response.verified)
|
||||
}
|
||||
|
||||
/// Get the currently authenticated user
|
||||
pub async fn get_current_user(&self) -> VRCResult<User> {
|
||||
let cookie_header = {
|
||||
let cookies = self.cookies.lock().await;
|
||||
cookies.to_header_value()
|
||||
};
|
||||
|
||||
let headers = self.build_headers(None, None, cookie_header.as_deref());
|
||||
|
||||
let response = self
|
||||
.execute_request(
|
||||
self.http_client
|
||||
.get(&format!("{}/auth/user", API_BASE_URL))
|
||||
.headers(headers),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Failed to get user".to_string());
|
||||
return Err(VRCError::http(status.as_u16(), error_text));
|
||||
}
|
||||
|
||||
let body = response.text().await?;
|
||||
let user: User = serde_json::from_str(&body)?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Update the user's status and status description
|
||||
pub async fn update_status(&self, request: &UpdateStatusRequest) -> VRCResult<User> {
|
||||
// First get current user to get their userId
|
||||
let current_user = self.get_current_user().await?;
|
||||
|
||||
let cookie_header = {
|
||||
let cookies = self.cookies.lock().await;
|
||||
cookies.to_header_value()
|
||||
};
|
||||
|
||||
let cookie = cookie_header.ok_or_else(|| VRCError::auth("Not authenticated"))?;
|
||||
let headers = self.build_headers(None, None, Some(&cookie));
|
||||
|
||||
let response = self
|
||||
.execute_request(
|
||||
self.http_client
|
||||
.put(&format!("{}/users/{}", API_BASE_URL, current_user.id))
|
||||
.headers(headers)
|
||||
.json(request),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Failed to update status".to_string());
|
||||
return Err(VRCError::http(status.as_u16(), error_text));
|
||||
}
|
||||
|
||||
let user: User = response.json().await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Log out the current user
|
||||
pub async fn logout(&self) -> VRCResult<()> {
|
||||
let cookie_header = {
|
||||
let cookies = self.cookies.lock().await;
|
||||
cookies.to_header_value()
|
||||
};
|
||||
|
||||
if let Some(cookie) = cookie_header {
|
||||
let headers = self.build_headers(None, None, Some(&cookie));
|
||||
|
||||
let _ = self
|
||||
.execute_request(
|
||||
self.http_client
|
||||
.put(&format!("{}/logout", API_BASE_URL))
|
||||
.headers(headers),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut cookies = self.cookies.lock().await;
|
||||
cookies.clear();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get online friends list
|
||||
pub async fn get_online_friends(&self) -> VRCResult<Vec<LimitedUserFriend>> {
|
||||
self.fetch_friends(false).await
|
||||
}
|
||||
|
||||
pub async fn get_all_friends(&self) -> VRCResult<Vec<LimitedUserFriend>> {
|
||||
let mut combined = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
let mut online = self.fetch_friends(false).await?;
|
||||
for friend in online.drain(..) {
|
||||
seen.insert(friend.id.clone());
|
||||
combined.push(friend);
|
||||
}
|
||||
|
||||
let mut offline = self.fetch_friends(true).await?;
|
||||
for friend in offline.drain(..) {
|
||||
if seen.insert(friend.id.clone()) {
|
||||
combined.push(friend);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(combined)
|
||||
}
|
||||
|
||||
async fn fetch_friends(&self, offline: bool) -> VRCResult<Vec<LimitedUserFriend>> {
|
||||
let cookie_header = {
|
||||
let cookies = self.cookies.lock().await;
|
||||
cookies.to_header_value()
|
||||
};
|
||||
|
||||
let cookie = cookie_header.ok_or_else(|| VRCError::auth("Not authenticated"))?;
|
||||
let mut results = Vec::new();
|
||||
let mut offset = 0usize;
|
||||
const PAGE_SIZE: usize = 100;
|
||||
|
||||
loop {
|
||||
let headers = self.build_headers(None, None, Some(&cookie));
|
||||
let response = self
|
||||
.execute_request(
|
||||
self.http_client
|
||||
.get(&format!(
|
||||
"{}/auth/user/friends?offline={}&n={}&offset={}",
|
||||
API_BASE_URL, offline, PAGE_SIZE, offset
|
||||
))
|
||||
.headers(headers),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(VRCError::http(
|
||||
response.status().as_u16(),
|
||||
"Failed to fetch friends",
|
||||
));
|
||||
}
|
||||
|
||||
let page: Vec<LimitedUserFriend> = response.json().await?;
|
||||
let count = page.len();
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
results.extend(page.into_iter());
|
||||
|
||||
if count < PAGE_SIZE {
|
||||
break;
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
offset += PAGE_SIZE;
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Fetch all worlds uploaded by the authenticated user
|
||||
pub async fn get_uploaded_worlds(&self) -> VRCResult<Vec<LimitedWorld>> {
|
||||
let cookie_header = {
|
||||
let cookies = self.cookies.lock().await;
|
||||
cookies.to_header_value()
|
||||
};
|
||||
|
||||
let cookie = cookie_header.ok_or_else(|| VRCError::auth("Not authenticated"))?;
|
||||
let mut worlds = Vec::new();
|
||||
let mut offset: usize = 0;
|
||||
const PAGE_SIZE: usize = 100;
|
||||
|
||||
loop {
|
||||
let headers = self.build_headers(None, None, Some(&cookie));
|
||||
let url = format!(
|
||||
"{}/worlds?user=me&n={}&offset={}&order=descending&sort=updated",
|
||||
API_BASE_URL, PAGE_SIZE, offset
|
||||
);
|
||||
|
||||
let response = self
|
||||
.execute_request(self.http_client.get(&url).headers(headers))
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(VRCError::http(
|
||||
response.status().as_u16(),
|
||||
"Failed to fetch uploaded worlds",
|
||||
));
|
||||
}
|
||||
|
||||
let mut page: Vec<LimitedWorld> = response.json().await?;
|
||||
let count = page.len();
|
||||
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
// Backfill statistics that might be missing from the list endpoint
|
||||
for idx in 0..page.len() {
|
||||
if page[idx].visits.is_none()
|
||||
|| page[idx].favorites.is_none()
|
||||
|| page[idx].popularity.is_none()
|
||||
|| page[idx].occupants.is_none()
|
||||
|| page[idx].capacity.is_none()
|
||||
|| page[idx].recommended_capacity.is_none()
|
||||
{
|
||||
match self.get_world_details(&page[idx].id).await {
|
||||
Ok(details) => {
|
||||
if details.visits.is_some() {
|
||||
page[idx].visits = details.visits;
|
||||
}
|
||||
if details.favorites.is_some() {
|
||||
page[idx].favorites = details.favorites;
|
||||
}
|
||||
if details.popularity.is_some() {
|
||||
page[idx].popularity = details.popularity;
|
||||
}
|
||||
if details.occupants.is_some() {
|
||||
page[idx].occupants = details.occupants;
|
||||
}
|
||||
if details.capacity.is_some() {
|
||||
page[idx].capacity = details.capacity;
|
||||
}
|
||||
if details.recommended_capacity.is_some() {
|
||||
page[idx].recommended_capacity = details.recommended_capacity;
|
||||
}
|
||||
if details.heat.is_some() {
|
||||
page[idx].heat = details.heat;
|
||||
}
|
||||
if details.organization.is_some() {
|
||||
page[idx].organization = details.organization;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to load additional details for world {}: {}",
|
||||
page[idx].id,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset += count;
|
||||
worlds.append(&mut page);
|
||||
|
||||
if count < PAGE_SIZE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(worlds)
|
||||
}
|
||||
|
||||
/// Fetch all avatars uploaded by the authenticated user
|
||||
pub async fn get_uploaded_avatars(&self) -> VRCResult<Vec<LimitedAvatar>> {
|
||||
let cookie_header = {
|
||||
let cookies = self.cookies.lock().await;
|
||||
cookies.to_header_value()
|
||||
};
|
||||
|
||||
let cookie = cookie_header.ok_or_else(|| VRCError::auth("Not authenticated"))?;
|
||||
let mut avatars = Vec::new();
|
||||
let mut offset: usize = 0;
|
||||
const PAGE_SIZE: usize = 100;
|
||||
|
||||
loop {
|
||||
let headers = self.build_headers(None, None, Some(&cookie));
|
||||
let url = format!(
|
||||
"{}/avatars?user=me&releaseStatus=all&sort=updated&order=descending&n={}&offset={}",
|
||||
API_BASE_URL, PAGE_SIZE, offset
|
||||
);
|
||||
|
||||
let response = self
|
||||
.execute_request(self.http_client.get(&url).headers(headers))
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(VRCError::http(
|
||||
response.status().as_u16(),
|
||||
"Failed to fetch uploaded avatars",
|
||||
));
|
||||
}
|
||||
|
||||
let mut page: Vec<LimitedAvatar> = response.json().await?;
|
||||
let count = page.len();
|
||||
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
offset += count;
|
||||
avatars.append(&mut page);
|
||||
|
||||
if count < PAGE_SIZE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(avatars)
|
||||
}
|
||||
|
||||
// TODO: Have to analyses consequences of rate limits when fetching world details in a loop
|
||||
/// Fetch additional details for a specific world
|
||||
pub async fn get_world_details(&self, world_id: &str) -> VRCResult<LimitedWorld> {
|
||||
let cookie_header = {
|
||||
let cookies = self.cookies.lock().await;
|
||||
cookies.to_header_value()
|
||||
};
|
||||
|
||||
let cookie = cookie_header.ok_or_else(|| VRCError::auth("Not authenticated"))?;
|
||||
let headers = self.build_headers(None, None, Some(&cookie));
|
||||
|
||||
let response = self
|
||||
.execute_request(
|
||||
self.http_client
|
||||
.get(&format!("{}/worlds/{}", API_BASE_URL, world_id))
|
||||
.headers(headers),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(VRCError::http(
|
||||
response.status().as_u16(),
|
||||
format!("Failed to fetch world {}", world_id),
|
||||
));
|
||||
}
|
||||
|
||||
let world: LimitedWorld = response.json().await?;
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
/// Fetch full user data by user ID
|
||||
pub async fn get_user_by_id(&self, user_id: &str) -> VRCResult<User> {
|
||||
let cookie_header = {
|
||||
let cookies = self.cookies.lock().await;
|
||||
cookies.to_header_value()
|
||||
};
|
||||
|
||||
let cookie = cookie_header.ok_or_else(|| VRCError::auth("Not authenticated"))?;
|
||||
let headers = self.build_headers(None, None, Some(&cookie));
|
||||
|
||||
let response = self
|
||||
.execute_request(
|
||||
self.http_client
|
||||
.get(&format!("{}/users/{}", API_BASE_URL, user_id))
|
||||
.headers(headers),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
let error_body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unable to read error response".to_string());
|
||||
log::error!(
|
||||
"Failed to fetch user {}: HTTP {} - {}",
|
||||
user_id,
|
||||
status.as_u16(),
|
||||
error_body
|
||||
);
|
||||
return Err(VRCError::http(
|
||||
status.as_u16(),
|
||||
format!("Failed to fetch user {}: {}", user_id, error_body),
|
||||
));
|
||||
}
|
||||
|
||||
let body = response.text().await?;
|
||||
log::debug!("User API response for {}: {}", user_id, body);
|
||||
|
||||
let user: User = serde_json::from_str(&body).map_err(|e| {
|
||||
log::error!("Failed to parse user JSON: {}", e);
|
||||
VRCError::unknown(format!("Failed to parse user data: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
// Session Management
|
||||
|
||||
/// Check if the client has a valid session
|
||||
pub async fn has_valid_session(&self) -> bool {
|
||||
let cookies = self.cookies.lock().await;
|
||||
cookies.has_auth()
|
||||
}
|
||||
|
||||
/// Export cookies for storage
|
||||
pub async fn export_cookies(&self) -> (Option<String>, Option<String>) {
|
||||
let cookies = self.cookies.lock().await;
|
||||
(
|
||||
cookies.auth_cookie.clone(),
|
||||
cookies.two_factor_cookie.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Import previously stored cookies
|
||||
pub async fn import_cookies(&self, auth: Option<String>, two_factor: Option<String>) {
|
||||
let mut cookies = self.cookies.lock().await;
|
||||
cookies.auth_cookie = auth;
|
||||
cookies.two_factor_cookie = two_factor;
|
||||
}
|
||||
|
||||
/// Clear all stored cookies
|
||||
pub async fn clear_cookies(&self) {
|
||||
let mut cookies = self.cookies.lock().await;
|
||||
cookies.clear();
|
||||
}
|
||||
|
||||
// Private Helper Methods
|
||||
|
||||
fn create_basic_auth(email: &str, password: &str) -> String {
|
||||
let credentials = format!("{}:{}", email, password);
|
||||
let encoded = BASE64.encode(credentials.as_bytes());
|
||||
format!("Basic {}", encoded)
|
||||
}
|
||||
|
||||
fn build_headers(
|
||||
&self,
|
||||
auth: Option<&str>,
|
||||
_referer: Option<&str>,
|
||||
cookie: Option<&str>,
|
||||
) -> HeaderMap {
|
||||
crate::http_common::build_api_headers(auth, cookie)
|
||||
}
|
||||
|
||||
async fn execute_request(&self, builder: RequestBuilder) -> VRCResult<Response> {
|
||||
let request = builder
|
||||
.build()
|
||||
.map_err(|e| VRCError::network(format!("Failed to build request: {}", e)))?;
|
||||
self.send_with_retry(request).await
|
||||
}
|
||||
|
||||
async fn send_with_retry(&self, request: Request) -> VRCResult<Response> {
|
||||
let mut attempt: u8 = 0;
|
||||
let mut backoff = Duration::from_millis(INITIAL_BACKOFF);
|
||||
|
||||
loop {
|
||||
let req = request
|
||||
.try_clone()
|
||||
.ok_or_else(|| VRCError::network("Failed to clone request for retry attempts"))?;
|
||||
|
||||
match self.http_client.execute(req).await {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
|
||||
if status.as_u16() == 429 {
|
||||
if attempt >= MAX_REQUEST_RETRIES {
|
||||
return Err(VRCError::rate_limit(
|
||||
"Too many requests. Please wait before trying again.",
|
||||
));
|
||||
}
|
||||
|
||||
let wait = Self::extract_retry_after(&response).unwrap_or(backoff);
|
||||
drop(response);
|
||||
sleep(wait).await;
|
||||
attempt += 1;
|
||||
backoff = (backoff * 2).min(Duration::from_millis(MAX_BACKOFF));
|
||||
continue;
|
||||
}
|
||||
|
||||
if status.is_server_error() {
|
||||
if attempt >= MAX_REQUEST_RETRIES {
|
||||
return Err(VRCError::http(
|
||||
status.as_u16(),
|
||||
status
|
||||
.canonical_reason()
|
||||
.unwrap_or("Server error")
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let wait = Self::extract_retry_after(&response).unwrap_or(backoff);
|
||||
drop(response);
|
||||
sleep(wait).await;
|
||||
attempt += 1;
|
||||
backoff = (backoff * 2).min(Duration::from_millis(MAX_BACKOFF));
|
||||
continue;
|
||||
}
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
Err(err) => {
|
||||
if attempt >= MAX_REQUEST_RETRIES {
|
||||
return Err(VRCError::network(format!(
|
||||
"Request failed after retries: {}",
|
||||
err
|
||||
)));
|
||||
}
|
||||
|
||||
sleep(backoff).await;
|
||||
attempt += 1;
|
||||
backoff = (backoff * 2).min(Duration::from_millis(MAX_BACKOFF));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_retry_after(response: &Response) -> Option<Duration> {
|
||||
response
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|header| header.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VRChatClient {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("Failed to create default VRChatClient")
|
||||
}
|
||||
}
|
||||
129
src-tauri/src/vrchat_api/error.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::fmt;
|
||||
|
||||
/// Result type alias for VRChat API operations
|
||||
pub type VRCResult<T> = Result<T, VRCError>;
|
||||
|
||||
/// Main error type for VRChat API operations
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
pub enum VRCError {
|
||||
/// Network-related errors
|
||||
Network(String),
|
||||
|
||||
/// HTTP errors with status code
|
||||
Http { status: u16, message: String },
|
||||
|
||||
/// Authentication errors
|
||||
Authentication(String),
|
||||
|
||||
/// Rate limiting error
|
||||
RateLimit(String),
|
||||
|
||||
/// JSON parsing errors
|
||||
Parse(String),
|
||||
|
||||
/// Invalid input or request
|
||||
InvalidInput(String),
|
||||
|
||||
/// Unknown or unexpected errors
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl VRCError {
|
||||
/// Create a new HTTP error
|
||||
pub fn http(status: u16, message: impl Into<String>) -> Self {
|
||||
Self::Http {
|
||||
status,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new network error
|
||||
pub fn network(message: impl Into<String>) -> Self {
|
||||
Self::Network(message.into())
|
||||
}
|
||||
|
||||
/// Create a new authentication error
|
||||
pub fn auth(message: impl Into<String>) -> Self {
|
||||
Self::Authentication(message.into())
|
||||
}
|
||||
|
||||
/// Create a new rate limit error
|
||||
pub fn rate_limit(message: impl Into<String>) -> Self {
|
||||
Self::RateLimit(message.into())
|
||||
}
|
||||
|
||||
/// Create a new parse error
|
||||
pub fn parse(message: impl Into<String>) -> Self {
|
||||
Self::Parse(message.into())
|
||||
}
|
||||
|
||||
/// Create a new invalid input error
|
||||
pub fn invalid_input(message: impl Into<String>) -> Self {
|
||||
Self::InvalidInput(message.into())
|
||||
}
|
||||
|
||||
/// Create an unknown error
|
||||
pub fn unknown(message: impl Into<String>) -> Self {
|
||||
Self::Unknown(message.into())
|
||||
}
|
||||
|
||||
/// Get the error message
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
Self::Network(msg)
|
||||
| Self::Http { message: msg, .. }
|
||||
| Self::Authentication(msg)
|
||||
| Self::RateLimit(msg)
|
||||
| Self::Parse(msg)
|
||||
| Self::InvalidInput(msg)
|
||||
| Self::Unknown(msg) => msg,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the HTTP status code if applicable
|
||||
pub fn status_code(&self) -> Option<u16> {
|
||||
match self {
|
||||
Self::Http { status, .. } => Some(*status),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VRCError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Network(msg) => write!(f, "Network error: {}", msg),
|
||||
Self::Http { status, message } => write!(f, "HTTP {} error: {}", status, message),
|
||||
Self::Authentication(msg) => write!(f, "Authentication error: {}", msg),
|
||||
Self::RateLimit(msg) => write!(f, "Rate limit: {}", msg),
|
||||
Self::Parse(msg) => write!(f, "Parse error: {}", msg),
|
||||
Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
|
||||
Self::Unknown(msg) => write!(f, "Unknown error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VRCError {}
|
||||
|
||||
/// Convert reqwest errors to VRCError
|
||||
impl From<reqwest::Error> for VRCError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
if err.is_timeout() {
|
||||
VRCError::network("Request timed out")
|
||||
} else if err.is_connect() {
|
||||
VRCError::network("Failed to connect to VRChat API")
|
||||
} else {
|
||||
VRCError::network(err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert serde_json errors to VRCError
|
||||
impl From<serde_json::Error> for VRCError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
VRCError::parse(format!("JSON parsing failed: {}", err))
|
||||
}
|
||||
}
|
||||
8
src-tauri/src/vrchat_api/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
pub mod types;
|
||||
|
||||
// Re-export common types
|
||||
pub use client::VRChatClient;
|
||||
pub use error::{VRCError, VRCResult};
|
||||
pub use types::*;
|
||||
40
src-tauri/src/vrchat_api/types/auth.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use super::enums::UserStatus;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct LoginCredentials {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum LoginResult {
|
||||
Success { user: super::user::User },
|
||||
TwoFactorRequired { methods: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TwoFactorAuthResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub requires_two_factor_auth: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TwoFactorCode {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TwoFactorVerifyResponse {
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateStatusRequest {
|
||||
pub status: UserStatus,
|
||||
pub status_description: String,
|
||||
}
|
||||
69
src-tauri/src/vrchat_api/types/avatar.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use super::enums::ReleaseStatus;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AvatarPerformance {
|
||||
#[serde(default)]
|
||||
pub android: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ios: Option<String>,
|
||||
#[serde(default)]
|
||||
pub standalonewindows: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AvatarStyles {
|
||||
#[serde(default)]
|
||||
pub primary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub secondary: Option<String>,
|
||||
}
|
||||
|
||||
use crate::vrchat_api::types::world::UnityPackageSummary;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LimitedAvatar {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub author_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub author_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub thumbnail_image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub asset_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub unity_package_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub release_status: ReleaseStatus,
|
||||
#[serde(default)]
|
||||
pub featured: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub searchable: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub listing_date: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub version: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub performance: Option<AvatarPerformance>,
|
||||
#[serde(default)]
|
||||
pub styles: Option<AvatarStyles>,
|
||||
#[serde(default)]
|
||||
pub unity_packages: Vec<UnityPackageSummary>,
|
||||
}
|
||||
254
src-tauri/src/vrchat_api/types/enums.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// User's current status
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UserStatus {
|
||||
/// User is online and active
|
||||
Active,
|
||||
/// User is online and auto accepting invitations to join
|
||||
#[serde(rename = "join me")]
|
||||
JoinMe,
|
||||
/// User is online but is hiding their location and requires invitation to join
|
||||
#[serde(rename = "ask me")]
|
||||
AskMe,
|
||||
/// User is busy
|
||||
Busy,
|
||||
/// User is offline
|
||||
Offline,
|
||||
}
|
||||
|
||||
impl Default for UserStatus {
|
||||
fn default() -> Self {
|
||||
UserStatus::Offline
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UserStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
UserStatus::Active => write!(f, "active"),
|
||||
UserStatus::JoinMe => write!(f, "join me"),
|
||||
UserStatus::AskMe => write!(f, "ask me"),
|
||||
UserStatus::Busy => write!(f, "busy"),
|
||||
UserStatus::Offline => write!(f, "offline"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release status of avatars and worlds
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ReleaseStatus {
|
||||
/// Publicly released
|
||||
Public,
|
||||
/// Private/restricted access
|
||||
Private,
|
||||
/// Hidden from listings
|
||||
Hidden,
|
||||
// TODO: Should this be here? It's not really a status, more of a filter.
|
||||
/// Filter for all statuses
|
||||
All,
|
||||
}
|
||||
|
||||
impl Default for ReleaseStatus {
|
||||
fn default() -> Self {
|
||||
ReleaseStatus::Public
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ReleaseStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ReleaseStatus::Public => write!(f, "public"),
|
||||
ReleaseStatus::Private => write!(f, "private"),
|
||||
ReleaseStatus::Hidden => write!(f, "hidden"),
|
||||
ReleaseStatus::All => write!(f, "all"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User's developer type/staff level
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DeveloperType {
|
||||
/// Normal user
|
||||
None,
|
||||
/// Trusted user
|
||||
Trusted,
|
||||
/// VRChat Developer/Staff
|
||||
Internal,
|
||||
/// VRChat Moderator
|
||||
Moderator,
|
||||
}
|
||||
|
||||
impl Default for DeveloperType {
|
||||
fn default() -> Self {
|
||||
DeveloperType::None
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DeveloperType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DeveloperType::None => write!(f, "none"),
|
||||
DeveloperType::Trusted => write!(f, "trusted"),
|
||||
DeveloperType::Internal => write!(f, "internal"),
|
||||
DeveloperType::Moderator => write!(f, "moderator"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Age verification status
|
||||
/// `verified` is obsolete. according to the unofficial docs, Users who have verified and are 18+ can switch to `plus18` status.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AgeVerificationStatus {
|
||||
/// Age verification status is hidden
|
||||
Hidden,
|
||||
/// Legacy verified status (obsolete)
|
||||
Verified,
|
||||
/// User is verified to be 18+
|
||||
#[serde(rename = "18+")]
|
||||
Plus18,
|
||||
}
|
||||
|
||||
impl Default for AgeVerificationStatus {
|
||||
fn default() -> Self {
|
||||
AgeVerificationStatus::Hidden
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AgeVerificationStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AgeVerificationStatus::Hidden => write!(f, "hidden"),
|
||||
AgeVerificationStatus::Verified => write!(f, "verified"),
|
||||
AgeVerificationStatus::Plus18 => write!(f, "18+"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Friend request status
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
pub enum FriendRequestStatus {
|
||||
/// No friend request
|
||||
#[serde(rename = "")]
|
||||
None,
|
||||
/// Outgoing friend request pending
|
||||
#[serde(rename = "outgoing")]
|
||||
Outgoing,
|
||||
/// Incoming friend request pending
|
||||
#[serde(rename = "incoming")]
|
||||
Incoming,
|
||||
/// Completed friend request
|
||||
#[serde(rename = "completed")]
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl Default for FriendRequestStatus {
|
||||
fn default() -> Self {
|
||||
FriendRequestStatus::None
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FriendRequestStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
FriendRequestStatus::None => write!(f, ""),
|
||||
FriendRequestStatus::Outgoing => write!(f, "outgoing"),
|
||||
FriendRequestStatus::Incoming => write!(f, "incoming"),
|
||||
FriendRequestStatus::Completed => write!(f, "completed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State of the user
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UserState {
|
||||
/// User is offline
|
||||
Offline,
|
||||
/// User is active
|
||||
Active,
|
||||
/// User is online
|
||||
Online,
|
||||
}
|
||||
|
||||
impl Default for UserState {
|
||||
fn default() -> Self {
|
||||
UserState::Offline
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UserState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
UserState::Offline => write!(f, "offline"),
|
||||
UserState::Active => write!(f, "active"),
|
||||
UserState::Online => write!(f, "online"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar performance ratings
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
pub enum PerformanceRatings {
|
||||
/// No rating
|
||||
None,
|
||||
/// Excellent performance
|
||||
Excellent,
|
||||
/// Good performance
|
||||
Good,
|
||||
/// Medium performance
|
||||
Medium,
|
||||
/// Poor performance
|
||||
Poor,
|
||||
/// Very poor performance
|
||||
VeryPoor,
|
||||
}
|
||||
|
||||
impl Default for PerformanceRatings {
|
||||
fn default() -> Self {
|
||||
PerformanceRatings::None
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PerformanceRatings {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PerformanceRatings::None => write!(f, "None"),
|
||||
PerformanceRatings::Excellent => write!(f, "Excellent"),
|
||||
PerformanceRatings::Good => write!(f, "Good"),
|
||||
PerformanceRatings::Medium => write!(f, "Medium"),
|
||||
PerformanceRatings::Poor => write!(f, "Poor"),
|
||||
PerformanceRatings::VeryPoor => write!(f, "VeryPoor"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort order for API queries
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OrderOption {
|
||||
/// Ascending order
|
||||
Ascending,
|
||||
/// Descending order
|
||||
Descending,
|
||||
}
|
||||
|
||||
impl Default for OrderOption {
|
||||
fn default() -> Self {
|
||||
OrderOption::Descending
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OrderOption {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
OrderOption::Ascending => write!(f, "ascending"),
|
||||
OrderOption::Descending => write!(f, "descending"),
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src-tauri/src/vrchat_api/types/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
pub mod auth;
|
||||
pub mod avatar;
|
||||
pub mod enums;
|
||||
pub mod two_factor;
|
||||
pub mod user;
|
||||
pub mod world;
|
||||
|
||||
// Re-export common types at crate level
|
||||
pub use auth::*;
|
||||
pub use avatar::*;
|
||||
pub use enums::*;
|
||||
pub use two_factor::*;
|
||||
pub use user::*;
|
||||
pub use world::*;
|
||||
22
src-tauri/src/vrchat_api/types/two_factor.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TwoFactorMethod {
|
||||
EmailOtp,
|
||||
Totp,
|
||||
}
|
||||
|
||||
impl TwoFactorMethod {
|
||||
pub fn endpoint(&self) -> &'static str {
|
||||
match self {
|
||||
Self::EmailOtp => "emailotp",
|
||||
Self::Totp => "totp",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"emailotp" => Some(Self::EmailOtp),
|
||||
"totp" | "otp" => Some(Self::Totp),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
297
src-tauri/src/vrchat_api/types/user.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use super::enums::{UserStatus, DeveloperType, AgeVerificationStatus, FriendRequestStatus};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub accepted_privacy_version: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub accepted_tos_version: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub account_deletion_date: Option<String>,
|
||||
#[serde(default)]
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub status: UserStatus,
|
||||
#[serde(default)]
|
||||
pub status_description: String,
|
||||
#[serde(default)]
|
||||
pub status_first_time: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub status_history: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub bio: String,
|
||||
#[serde(default)]
|
||||
pub bio_links: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub age_verification_status: AgeVerificationStatus,
|
||||
#[serde(default)]
|
||||
pub age_verified: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub is_adult: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub date_joined: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_login: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_activity: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_platform: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_mobile: Option<String>,
|
||||
#[serde(default)]
|
||||
pub platform: String,
|
||||
#[serde(default)]
|
||||
pub platform_history: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub location: Option<String>,
|
||||
#[serde(default)]
|
||||
pub traveling_to_world: Option<String>,
|
||||
#[serde(default)]
|
||||
pub traveling_to_location: Option<String>,
|
||||
#[serde(default)]
|
||||
pub traveling_to_instance: Option<String>,
|
||||
#[serde(default)]
|
||||
pub home_location: Option<String>,
|
||||
#[serde(default)]
|
||||
pub instance_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub world_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub allow_avatar_copying: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub two_factor_auth_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub two_factor_auth_enabled_date: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_avatar: Option<String>,
|
||||
#[serde(default)]
|
||||
pub fallback_avatar: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_avatar_tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub profile_pic_override: Option<String>,
|
||||
#[serde(default)]
|
||||
pub profile_pic_override_thumbnail: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user_icon: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_avatar_image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_avatar_thumbnail_image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub banner_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub banner_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pronouns: Option<String>,
|
||||
#[serde(default)]
|
||||
pub languages: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub pronouns_history: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub friends: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub friend_group_names: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub friend_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub friend_request_status: FriendRequestStatus,
|
||||
#[serde(default)]
|
||||
pub past_display_names: Option<Vec<PastDisplayName>>,
|
||||
#[serde(default)]
|
||||
pub badges: Option<Vec<Badge>>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub is_friend: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
#[serde(default)]
|
||||
pub developer_type: DeveloperType,
|
||||
#[serde(default)]
|
||||
pub is_booping_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub receive_mobile_invitations: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub hide_content_filter_settings: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub has_birthday: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub has_email: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub has_pending_email: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub has_logged_in_from_client: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub unsubscribe: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub email_verified: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub obfuscated_email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user_language: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user_language_code: Option<String>,
|
||||
#[serde(default)]
|
||||
pub discord_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub discord_details: Option<DiscordDetails>,
|
||||
#[serde(default)]
|
||||
pub google_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub google_details: Option<GoogleDetails>,
|
||||
#[serde(default)]
|
||||
pub steam_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub steam_details: Option<SteamDetails>,
|
||||
#[serde(default)]
|
||||
pub oculus_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pico_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub vive_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiscordDetails {
|
||||
#[serde(default)]
|
||||
pub global_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GoogleDetails {
|
||||
#[serde(default)]
|
||||
pub email_matches: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SteamDetails {
|
||||
#[serde(default)]
|
||||
pub avatar: Option<String>,
|
||||
#[serde(default)]
|
||||
pub avatarfull: Option<String>,
|
||||
#[serde(default)]
|
||||
pub avatarhash: Option<String>,
|
||||
#[serde(default)]
|
||||
pub avatarmedium: Option<String>,
|
||||
#[serde(default)]
|
||||
pub communityvisibilitystate: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub gameextrainfo: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gameid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub loccountrycode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub locstatecode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub personaname: Option<String>,
|
||||
#[serde(default)]
|
||||
pub personastate: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub personastateflags: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub primaryclanid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub profilestate: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub profileurl: Option<String>,
|
||||
#[serde(default)]
|
||||
pub steamid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub timecreated: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PastDisplayName {
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reverted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Badge {
|
||||
pub badge_id: String,
|
||||
#[serde(default)]
|
||||
pub badge_name: String,
|
||||
#[serde(default)]
|
||||
pub badge_description: String,
|
||||
#[serde(default)]
|
||||
pub assigned_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub showcased: bool,
|
||||
#[serde(default)]
|
||||
pub badge_image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LimitedUserFriend {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub bio: String,
|
||||
#[serde(default)]
|
||||
pub bio_links: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub current_avatar_image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_avatar_thumbnail_image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_avatar_tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub developer_type: DeveloperType,
|
||||
#[serde(default)]
|
||||
pub friend_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_friend: bool,
|
||||
#[serde(default)]
|
||||
pub image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_platform: Option<String>,
|
||||
#[serde(default)]
|
||||
pub location: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_login: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_activity: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_mobile: Option<String>,
|
||||
#[serde(default)]
|
||||
pub platform: String,
|
||||
#[serde(default)]
|
||||
pub profile_pic_override: Option<String>,
|
||||
#[serde(default)]
|
||||
pub profile_pic_override_thumbnail: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: UserStatus,
|
||||
#[serde(default)]
|
||||
pub status_description: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub user_icon: Option<String>,
|
||||
}
|
||||
80
src-tauri/src/vrchat_api/types/world.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use super::enums::ReleaseStatus;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnityPackageSummary {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub asset_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub asset_version: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
#[serde(default)]
|
||||
pub unity_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub performance_rating: Option<String>,
|
||||
#[serde(default)]
|
||||
pub scan_status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub variant: Option<String>,
|
||||
#[serde(default)]
|
||||
pub unity_sort_number: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub impostorizer_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LimitedWorld {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub author_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub author_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub thumbnail_image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub release_status: ReleaseStatus,
|
||||
#[serde(default)]
|
||||
pub publication_date: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub labs_publication_date: Option<String>,
|
||||
#[serde(default)]
|
||||
pub visits: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub favorites: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub popularity: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub occupants: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub capacity: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub recommended_capacity: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub heat: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub organization: Option<String>,
|
||||
#[serde(default)]
|
||||
pub preview_youtube_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub unity_packages: Vec<UnityPackageSummary>,
|
||||
}
|
||||
100
src-tauri/src/vrchat_status.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
const VRCHAT_STATUS_URL: &str = "https://status.vrchat.com/api/v2/status.json";
|
||||
|
||||
/// Response from VRChat status API
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct VRChatStatusResponse {
|
||||
pub page: StatusPage,
|
||||
pub status: SystemStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct StatusPage {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
#[serde(rename = "time_zone")]
|
||||
pub time_zone: String,
|
||||
#[serde(rename = "updated_at")]
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct SystemStatus {
|
||||
/// Indicator of system status
|
||||
pub indicator: StatusIndicator,
|
||||
/// Human-readable status description
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl SystemStatus {
|
||||
/// Check if the system is operating normally
|
||||
pub fn is_healthy(&self) -> bool {
|
||||
matches!(self.indicator, StatusIndicator::None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StatusIndicator {
|
||||
None,
|
||||
Minor,
|
||||
Major,
|
||||
Critical,
|
||||
}
|
||||
|
||||
/// Fetch current VRChat service status
|
||||
pub async fn fetch_vrchat_status() -> Result<VRChatStatusResponse, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
let response = client
|
||||
.get(VRCHAT_STATUS_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch status: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP error: {}", response.status()));
|
||||
}
|
||||
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response text: {}", e))?;
|
||||
|
||||
log::debug!("VRChat status API response: {}", response_text);
|
||||
|
||||
let status = serde_json::from_str::<VRChatStatusResponse>(&response_text).map_err(|e| {
|
||||
format!(
|
||||
"Failed to parse status response: {}. Response was: {}",
|
||||
e, response_text
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_severity_levels() {
|
||||
let healthy = SystemStatus {
|
||||
indicator: StatusIndicator::None,
|
||||
description: "All Systems Operational".to_string(),
|
||||
};
|
||||
assert!(healthy.is_healthy());
|
||||
|
||||
let major = SystemStatus {
|
||||
indicator: StatusIndicator::Major,
|
||||
description: "Partial System Outage".to_string(),
|
||||
};
|
||||
assert!(!major.is_healthy());
|
||||
}
|
||||
}
|
||||
536
src-tauri/src/websocket/client.rs
Normal file
@@ -0,0 +1,536 @@
|
||||
use futures_util::StreamExt;
|
||||
use http::Request;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::Duration;
|
||||
use tokio_tungstenite::{
|
||||
connect_async,
|
||||
tungstenite::{Message, handshake::client::generate_key},
|
||||
};
|
||||
|
||||
use super::types::*;
|
||||
use crate::store::{UserStore, user_store::CurrentUserPipelineUpdate};
|
||||
use crate::vrchat_api::error::{VRCError, VRCResult};
|
||||
|
||||
const PIPELINE_BASE_URL: &str = "wss://pipeline.vrchat.cloud/";
|
||||
const PIPELINE_HOST: &str = "pipeline.vrchat.cloud";
|
||||
use crate::http_common::USER_AGENT_STRING;
|
||||
// const HEARTBEAT_INTERVAL_SECS: u64 = 30;
|
||||
|
||||
pub struct VRChatWebSocket {
|
||||
auth_cookie: Arc<Mutex<Option<String>>>,
|
||||
two_factor_cookie: Arc<Mutex<Option<String>>>,
|
||||
app_handle: AppHandle,
|
||||
running: Arc<Mutex<bool>>,
|
||||
user_store: UserStore,
|
||||
}
|
||||
|
||||
impl VRChatWebSocket {
|
||||
pub fn new(app_handle: AppHandle, user_store: UserStore) -> Self {
|
||||
Self {
|
||||
auth_cookie: Arc::new(Mutex::new(None)),
|
||||
two_factor_cookie: Arc::new(Mutex::new(None)),
|
||||
app_handle,
|
||||
running: Arc::new(Mutex::new(false)),
|
||||
user_store,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_user_store(&self) -> UserStore {
|
||||
self.user_store.clone()
|
||||
}
|
||||
|
||||
pub async fn set_cookies(
|
||||
&self,
|
||||
auth_cookie: Option<String>,
|
||||
two_factor_cookie: Option<String>,
|
||||
) {
|
||||
let mut auth = self.auth_cookie.lock().await;
|
||||
*auth = auth_cookie;
|
||||
drop(auth);
|
||||
|
||||
let mut two_fa = self.two_factor_cookie.lock().await;
|
||||
*two_fa = two_factor_cookie;
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> VRCResult<()> {
|
||||
let mut running = self.running.lock().await;
|
||||
if *running {
|
||||
return Ok(());
|
||||
}
|
||||
*running = true;
|
||||
drop(running);
|
||||
|
||||
let auth_cookie = self.auth_cookie.clone();
|
||||
let two_factor_cookie = self.two_factor_cookie.clone();
|
||||
let app_handle = self.app_handle.clone();
|
||||
let running = self.running.clone();
|
||||
let user_store = self.user_store.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
Self::run_connection_loop(
|
||||
auth_cookie,
|
||||
two_factor_cookie,
|
||||
app_handle,
|
||||
running,
|
||||
user_store,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop(&self) {
|
||||
let mut running = self.running.lock().await;
|
||||
*running = false;
|
||||
}
|
||||
|
||||
async fn run_connection_loop(
|
||||
auth_cookie: Arc<Mutex<Option<String>>>,
|
||||
two_factor_cookie: Arc<Mutex<Option<String>>>,
|
||||
app_handle: AppHandle,
|
||||
running: Arc<Mutex<bool>>,
|
||||
user_store: UserStore,
|
||||
) {
|
||||
let mut reconnect_delay = 2;
|
||||
const MAX_RECONNECT_DELAY: u64 = 60;
|
||||
|
||||
loop {
|
||||
{
|
||||
let is_running = running.lock().await;
|
||||
if !*is_running {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let cookies = {
|
||||
let auth = auth_cookie.lock().await;
|
||||
let two_fa = two_factor_cookie.lock().await;
|
||||
(auth.clone(), two_fa.clone())
|
||||
};
|
||||
|
||||
if cookies.0.is_none() {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
match Self::connect_and_listen(
|
||||
&cookies.0.unwrap(),
|
||||
cookies.1.as_deref(),
|
||||
&app_handle,
|
||||
&running,
|
||||
&user_store,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
// Reset delay on successful connection
|
||||
reconnect_delay = 2;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("WebSocket error: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let is_running = running.lock().await;
|
||||
if !*is_running {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Exponential backoff for reconnection
|
||||
log::debug!("Reconnecting WebSocket in {} seconds...", reconnect_delay);
|
||||
tokio::time::sleep(Duration::from_secs(reconnect_delay)).await;
|
||||
reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_and_listen(
|
||||
auth_cookie: &str,
|
||||
two_factor_cookie: Option<&str>,
|
||||
app_handle: &AppHandle,
|
||||
running: &Arc<Mutex<bool>>,
|
||||
user_store: &UserStore,
|
||||
) -> VRCResult<()> {
|
||||
let auth_cookie_value = auth_cookie.split(';').next().unwrap_or(auth_cookie).trim();
|
||||
let auth_token = auth_cookie_value
|
||||
.splitn(2, '=')
|
||||
.nth(1)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
VRCError::invalid_input(
|
||||
"Auth cookie missing auth token required for pipeline WebSocket",
|
||||
)
|
||||
})?;
|
||||
let websocket_url = format!("{PIPELINE_BASE_URL}?authToken={}", auth_token);
|
||||
|
||||
log::debug!("Attempting WebSocket connection to: {}", websocket_url);
|
||||
log::trace!(
|
||||
"Using auth cookie (first 20 chars): {}...",
|
||||
&auth_cookie_value.chars().take(20).collect::<String>()
|
||||
);
|
||||
|
||||
// Build Cookie
|
||||
let mut cookie_parts = vec![auth_cookie_value.to_string()];
|
||||
if let Some(two_fa) = two_factor_cookie {
|
||||
let two_fa_value = two_fa.split(';').next().unwrap_or(two_fa).trim();
|
||||
if !two_fa_value.is_empty() {
|
||||
cookie_parts.push(two_fa_value.to_string());
|
||||
}
|
||||
}
|
||||
let cookie_header = cookie_parts.join("; ");
|
||||
|
||||
// Build WebSocket request
|
||||
let ws_key = generate_key();
|
||||
let request = Request::builder()
|
||||
.method("GET")
|
||||
.uri(&websocket_url)
|
||||
.header("Host", PIPELINE_HOST)
|
||||
.header("User-Agent", USER_AGENT_STRING)
|
||||
.header("Connection", "Upgrade")
|
||||
.header("Upgrade", "websocket")
|
||||
.header("Sec-WebSocket-Version", "13")
|
||||
.header("Sec-WebSocket-Key", ws_key)
|
||||
.header("Cookie", cookie_header)
|
||||
.body(())
|
||||
.map_err(|e| VRCError::network(format!("Failed to build WebSocket request: {}", e)))?;
|
||||
|
||||
let (ws_stream, response) = connect_async(request)
|
||||
.await
|
||||
.map_err(|e| VRCError::network(format!("WebSocket connection failed: {}", e)))?;
|
||||
|
||||
log::debug!(
|
||||
"WebSocket handshake response status: {:?}",
|
||||
response.status()
|
||||
);
|
||||
|
||||
log::info!("WebSocket connected");
|
||||
let _ = app_handle.emit("websocket-connected", ());
|
||||
|
||||
let (_write, mut read) = ws_stream.split();
|
||||
|
||||
// Ping task
|
||||
// Disabled, as VRChat pipeline seems to not require pings.
|
||||
// let running_ping = running.clone();
|
||||
// let ping_task = tokio::spawn(async move {
|
||||
// let mut ping_interval = interval(Duration::from_secs(HEARTBEAT_INTERVAL_SECS));
|
||||
// loop {
|
||||
// ping_interval.tick().await;
|
||||
// {
|
||||
// let is_running = running_ping.lock().await;
|
||||
// if !*is_running {
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// if write.send(Message::Ping(vec![])).await.is_err() {
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
// Main message loop
|
||||
while let Some(msg) = read.next().await {
|
||||
{
|
||||
let is_running = running.lock().await;
|
||||
if !*is_running {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
if let Err(e) = Self::handle_message(&text, app_handle, user_store).await {
|
||||
log::error!("Error handling WebSocket message: {:?}", e);
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
log::info!("WebSocket closed by server");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("WebSocket read error: {:?}", e);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
//ping_task.abort();
|
||||
let _ = app_handle.emit("websocket-disconnected", ());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_message(
|
||||
text: &str,
|
||||
app_handle: &AppHandle,
|
||||
user_store: &UserStore,
|
||||
) -> VRCResult<()> {
|
||||
log::trace!("WebSocket Message Received: {}", text);
|
||||
|
||||
// Parse the outer envelope
|
||||
let message: WebSocketMessage = serde_json::from_str(text)
|
||||
.map_err(|e| VRCError::parse(format!("Failed to parse WebSocket message: {}", e)))?;
|
||||
|
||||
// Handle different message types
|
||||
match message {
|
||||
WebSocketMessage::Notification(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::trace!(
|
||||
"Notification event: {}",
|
||||
payload.kind.as_deref().unwrap_or("unknown")
|
||||
);
|
||||
let _ = app_handle.emit("vrchat-notification", &payload);
|
||||
}
|
||||
WebSocketMessage::ResponseNotification(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::trace!(
|
||||
"Notification response: notification={}, response={}",
|
||||
payload.notification_id,
|
||||
payload.response_id
|
||||
);
|
||||
let _ = app_handle.emit("vrchat-notification-response", &payload);
|
||||
}
|
||||
WebSocketMessage::SeeNotification(notification_id) => {
|
||||
let notification_id = notification_id.into_inner();
|
||||
log::trace!("Notification seen: {}", notification_id);
|
||||
let _ = app_handle.emit("vrchat-notification-see", ¬ification_id);
|
||||
}
|
||||
WebSocketMessage::HideNotification(notification_id) => {
|
||||
let notification_id = notification_id.into_inner();
|
||||
log::trace!("Notification hide requested: {}", notification_id);
|
||||
let _ = app_handle.emit("vrchat-notification-hide", ¬ification_id);
|
||||
}
|
||||
WebSocketMessage::ClearNotification => {
|
||||
log::trace!("Notification clear requested");
|
||||
let _ = app_handle.emit("vrchat-notification-clear", ());
|
||||
}
|
||||
WebSocketMessage::NotificationV2(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::trace!("Notification v2: {}", payload.kind);
|
||||
let _ = app_handle.emit("vrchat-notification-v2", &payload);
|
||||
}
|
||||
WebSocketMessage::NotificationV2Update(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::trace!("Notification v2 update: {}", payload.id);
|
||||
let _ = app_handle.emit("vrchat-notification-v2-update", &payload);
|
||||
}
|
||||
WebSocketMessage::NotificationV2Delete(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::trace!("Notification v2 delete: {} ids", payload.ids.len());
|
||||
let _ = app_handle.emit("vrchat-notification-v2-delete", &payload);
|
||||
}
|
||||
WebSocketMessage::FriendAdd(payload) => {
|
||||
let content = payload.into_inner();
|
||||
log::info!(
|
||||
"Friend added: {} ({})",
|
||||
content.user.display_name,
|
||||
content.user_id
|
||||
);
|
||||
user_store.upsert_friend(content.user.clone()).await;
|
||||
let event = FriendUpdateEvent {
|
||||
user_id: content.user_id.clone(),
|
||||
user: content.user.clone(),
|
||||
};
|
||||
let _ = app_handle.emit("friend-added", &event);
|
||||
let _ = app_handle.emit("friend-update", &event);
|
||||
}
|
||||
WebSocketMessage::FriendDelete(payload) => {
|
||||
let content = payload.into_inner();
|
||||
log::info!("Friend removed: {}", content.user_id);
|
||||
user_store.remove_friend(&content.user_id).await;
|
||||
let event = FriendRemovedEvent {
|
||||
user_id: content.user_id.clone(),
|
||||
};
|
||||
let _ = app_handle.emit("friend-removed", &event);
|
||||
}
|
||||
WebSocketMessage::FriendUpdate(payload) => {
|
||||
let content = payload.into_inner();
|
||||
log::debug!(
|
||||
"Friend updated: {} ({})",
|
||||
content.user.display_name,
|
||||
content.user_id
|
||||
);
|
||||
user_store.upsert_friend(content.user.clone()).await;
|
||||
let event = FriendUpdateEvent {
|
||||
user_id: content.user_id,
|
||||
user: content.user,
|
||||
};
|
||||
let _ = app_handle.emit("friend-update", &event);
|
||||
}
|
||||
WebSocketMessage::FriendOnline(payload) => {
|
||||
let content = payload.into_inner();
|
||||
log::info!(
|
||||
"Friend online: {} ({})",
|
||||
content.user.display_name,
|
||||
content.user_id
|
||||
);
|
||||
user_store.upsert_friend(content.user.clone()).await;
|
||||
if let Some(location) = content.location.clone() {
|
||||
user_store
|
||||
.update_user_location(&content.user_id, location, content.platform.clone())
|
||||
.await;
|
||||
}
|
||||
let event = FriendOnlineEvent {
|
||||
user_id: content.user_id,
|
||||
user: content.user,
|
||||
};
|
||||
let _ = app_handle.emit("friend-online", &event);
|
||||
}
|
||||
WebSocketMessage::FriendActive(payload) => {
|
||||
let content = payload.into_inner();
|
||||
log::debug!(
|
||||
"Friend active: {} ({})",
|
||||
content.user.display_name,
|
||||
content.user_id
|
||||
);
|
||||
user_store.upsert_friend(content.user.clone()).await;
|
||||
let event = FriendOnlineEvent {
|
||||
user_id: content.user_id.clone(),
|
||||
user: content.user.clone(),
|
||||
};
|
||||
let _ = app_handle.emit("friend-active", &event);
|
||||
let _ = app_handle.emit("friend-online", &event);
|
||||
}
|
||||
WebSocketMessage::FriendOffline(payload) => {
|
||||
let content = payload.into_inner();
|
||||
log::info!("Friend offline: {}", content.user_id);
|
||||
user_store.set_friend_offline(&content.user_id).await;
|
||||
let event = FriendOfflineEvent {
|
||||
user_id: content.user_id,
|
||||
};
|
||||
let _ = app_handle.emit("friend-offline", &event);
|
||||
}
|
||||
WebSocketMessage::FriendLocation(payload) => {
|
||||
let content = payload.into_inner();
|
||||
log::debug!(
|
||||
"Friend location: {} -> {}",
|
||||
content.user_id,
|
||||
content.location
|
||||
);
|
||||
if let Some(user) = content.user.clone() {
|
||||
user_store.upsert_friend(user).await;
|
||||
}
|
||||
let platform = content.user.as_ref().map(|friend| friend.platform.clone());
|
||||
user_store
|
||||
.update_user_location(&content.user_id, content.location.clone(), platform)
|
||||
.await;
|
||||
let _ = app_handle.emit("friend-location", &content);
|
||||
}
|
||||
WebSocketMessage::UserUpdate(payload) => {
|
||||
let content = payload.into_inner();
|
||||
log::debug!("Current user update for {}", content.user_id);
|
||||
let user = content.user.clone();
|
||||
let patch = CurrentUserPipelineUpdate {
|
||||
id: user.id.clone(),
|
||||
display_name: user.display_name.clone(),
|
||||
username: user.username.clone(),
|
||||
status: user.status.clone(),
|
||||
status_description: user.status_description.clone(),
|
||||
bio: user.bio.clone(),
|
||||
user_icon: user.user_icon.clone(),
|
||||
profile_pic_override: user.profile_pic_override.clone(),
|
||||
profile_pic_override_thumbnail: user
|
||||
.profile_pic_override_thumbnail_image_url
|
||||
.clone(),
|
||||
current_avatar: user.current_avatar.clone(),
|
||||
current_avatar_asset_url: user.current_avatar_asset_url.clone(),
|
||||
current_avatar_image_url: user.current_avatar_image_url.clone(),
|
||||
current_avatar_thumbnail_image_url: user
|
||||
.current_avatar_thumbnail_image_url
|
||||
.clone(),
|
||||
fallback_avatar: user.fallback_avatar.clone(),
|
||||
tags: user.tags.clone(),
|
||||
};
|
||||
user_store.apply_current_user_update(patch).await;
|
||||
let _ = app_handle.emit("user-update", &content);
|
||||
}
|
||||
WebSocketMessage::UserLocation(payload) => {
|
||||
let content = payload.into_inner();
|
||||
log::debug!(
|
||||
"Current user location: {} -> {}",
|
||||
content.user_id,
|
||||
content.location
|
||||
);
|
||||
if let Some(user) = content.user.clone() {
|
||||
user_store.upsert_friend(user).await;
|
||||
}
|
||||
let platform = content.user.as_ref().map(|friend| friend.platform.clone());
|
||||
user_store
|
||||
.update_user_location(&content.user_id, content.location.clone(), platform)
|
||||
.await;
|
||||
let _ = app_handle.emit("user-location", &content);
|
||||
}
|
||||
WebSocketMessage::UserBadgeAssigned(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::info!("Badge assigned: {}", payload.badge.badge_id);
|
||||
let _ = app_handle.emit("user-badge-assigned", &payload);
|
||||
}
|
||||
WebSocketMessage::UserBadgeUnassigned(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::info!("Badge unassigned: {}", payload.badge_id);
|
||||
let _ = app_handle.emit("user-badge-unassigned", &payload);
|
||||
}
|
||||
WebSocketMessage::ContentRefresh(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::debug!(
|
||||
"Content refresh: {} {}",
|
||||
payload.content_type,
|
||||
payload.action_type.as_deref().unwrap_or("")
|
||||
);
|
||||
let _ = app_handle.emit("content-refresh", &payload);
|
||||
}
|
||||
WebSocketMessage::ModifiedImageUpdate(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::debug!("Image modified: {}", payload.file_id);
|
||||
let _ = app_handle.emit("modified-image-update", &payload);
|
||||
}
|
||||
WebSocketMessage::InstanceQueueJoined(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::info!(
|
||||
"Instance queue joined: {} (position {})",
|
||||
payload.instance_location,
|
||||
payload.position
|
||||
);
|
||||
let _ = app_handle.emit("instance-queue-joined", &payload);
|
||||
}
|
||||
WebSocketMessage::InstanceQueueReady(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::info!(
|
||||
"Instance queue ready: {} (expiry {})",
|
||||
payload.instance_location,
|
||||
payload.expiry
|
||||
);
|
||||
let _ = app_handle.emit("instance-queue-ready", &payload);
|
||||
}
|
||||
WebSocketMessage::GroupJoined(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::info!("Group joined: {}", payload.group_id);
|
||||
let _ = app_handle.emit("group-joined", &payload);
|
||||
}
|
||||
WebSocketMessage::GroupLeft(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::info!("Group left: {}", payload.group_id);
|
||||
let _ = app_handle.emit("group-left", &payload);
|
||||
}
|
||||
WebSocketMessage::GroupMemberUpdated(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::debug!("Group member updated event received");
|
||||
let _ = app_handle.emit("group-member-updated", &payload);
|
||||
}
|
||||
WebSocketMessage::GroupRoleUpdated(payload) => {
|
||||
let payload = payload.into_inner();
|
||||
log::debug!("Group role updated event received");
|
||||
let _ = app_handle.emit("group-role-updated", &payload);
|
||||
}
|
||||
WebSocketMessage::Unknown => {
|
||||
log::debug!("Unknown WebSocket message type");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
5
src-tauri/src/websocket/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod client;
|
||||
pub mod types;
|
||||
|
||||
pub use client::VRChatWebSocket;
|
||||
pub use types::*;
|
||||
505
src-tauri/src/websocket/types.rs
Normal file
@@ -0,0 +1,505 @@
|
||||
use crate::vrchat_api::types::LimitedUserFriend;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Wrapper that transparently handles VRChat's double-encoded message payloads.
|
||||
/// Some events ship their "content" field as a JSON string containing another
|
||||
/// JSON document. Others already provide structured JSON. This helper will
|
||||
/// attempt to deserialize the content value directly and, if that fails,
|
||||
/// attempt to parse the inner string as JSON and deserialize that instead.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DoubleEncoded<T> {
|
||||
inner: T,
|
||||
}
|
||||
|
||||
impl<T> DoubleEncoded<T> {
|
||||
pub fn into_inner(self) -> T {
|
||||
self.inner
|
||||
}
|
||||
|
||||
pub fn as_inner(&self) -> &T {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Serialize for DoubleEncoded<T>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.inner.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T> Deserialize<'de> for DoubleEncoded<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
let inner = decode_value::<T>(value).map_err(serde::de::Error::custom)?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_value<T>(value: Value) -> Result<T, serde_json::Error>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
match serde_json::from_value::<T>(value.clone()) {
|
||||
Ok(result) => Ok(result),
|
||||
Err(primary_err) => {
|
||||
if let Value::String(raw) = value {
|
||||
// Attempt to parse the string as JSON and then deserialize to the target type.
|
||||
match serde_json::from_str::<Value>(&raw) {
|
||||
Ok(decoded) => serde_json::from_value::<T>(decoded),
|
||||
Err(_) => Err(primary_err),
|
||||
}
|
||||
} else {
|
||||
Err(primary_err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket message envelope for pipeline events.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "content")]
|
||||
pub enum WebSocketMessage {
|
||||
#[serde(rename = "notification")]
|
||||
Notification(DoubleEncoded<NotificationPayload>),
|
||||
#[serde(rename = "response-notification")]
|
||||
ResponseNotification(DoubleEncoded<ResponseNotificationContent>),
|
||||
#[serde(rename = "see-notification")]
|
||||
SeeNotification(DoubleEncoded<String>),
|
||||
#[serde(rename = "hide-notification")]
|
||||
HideNotification(DoubleEncoded<String>),
|
||||
#[serde(rename = "clear-notification")]
|
||||
ClearNotification,
|
||||
#[serde(rename = "notification-v2")]
|
||||
NotificationV2(DoubleEncoded<NotificationV2Payload>),
|
||||
#[serde(rename = "notification-v2-update")]
|
||||
NotificationV2Update(DoubleEncoded<NotificationV2UpdatePayload>),
|
||||
#[serde(rename = "notification-v2-delete")]
|
||||
NotificationV2Delete(DoubleEncoded<NotificationV2DeletePayload>),
|
||||
#[serde(rename = "friend-add")]
|
||||
FriendAdd(DoubleEncoded<FriendAddContent>),
|
||||
#[serde(rename = "friend-delete")]
|
||||
FriendDelete(DoubleEncoded<FriendDeleteContent>),
|
||||
#[serde(rename = "friend-update")]
|
||||
FriendUpdate(DoubleEncoded<FriendUpdateContent>),
|
||||
#[serde(rename = "friend-online")]
|
||||
FriendOnline(DoubleEncoded<FriendOnlineContent>),
|
||||
#[serde(rename = "friend-active")]
|
||||
FriendActive(DoubleEncoded<FriendActiveContent>),
|
||||
#[serde(rename = "friend-offline")]
|
||||
FriendOffline(DoubleEncoded<FriendOfflineContent>),
|
||||
#[serde(rename = "friend-location")]
|
||||
FriendLocation(DoubleEncoded<FriendLocationContent>),
|
||||
#[serde(rename = "user-update")]
|
||||
UserUpdate(DoubleEncoded<UserUpdateContent>),
|
||||
#[serde(rename = "user-location")]
|
||||
UserLocation(DoubleEncoded<UserLocationContent>),
|
||||
#[serde(rename = "user-badge-assigned")]
|
||||
UserBadgeAssigned(DoubleEncoded<UserBadgeAssignedContent>),
|
||||
#[serde(rename = "user-badge-unassigned")]
|
||||
UserBadgeUnassigned(DoubleEncoded<UserBadgeUnassignedContent>),
|
||||
#[serde(rename = "content-refresh")]
|
||||
ContentRefresh(DoubleEncoded<ContentRefreshContent>),
|
||||
#[serde(rename = "modified-image-update")]
|
||||
ModifiedImageUpdate(DoubleEncoded<ModifiedImageUpdateContent>),
|
||||
#[serde(rename = "instance-queue-joined")]
|
||||
InstanceQueueJoined(DoubleEncoded<InstanceQueueJoinedContent>),
|
||||
#[serde(rename = "instance-queue-ready")]
|
||||
InstanceQueueReady(DoubleEncoded<InstanceQueueReadyContent>),
|
||||
#[serde(rename = "group-joined")]
|
||||
GroupJoined(DoubleEncoded<GroupChangedContent>),
|
||||
#[serde(rename = "group-left")]
|
||||
GroupLeft(DoubleEncoded<GroupChangedContent>),
|
||||
#[serde(rename = "group-member-updated")]
|
||||
GroupMemberUpdated(DoubleEncoded<GroupMemberUpdatedContent>),
|
||||
#[serde(rename = "group-role-updated")]
|
||||
GroupRoleUpdated(DoubleEncoded<GroupRoleUpdatedContent>),
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
// Notification payloads
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationPayload {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(rename = "type", default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub category: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sender_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sender_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub receiver_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub message: Option<String>,
|
||||
#[serde(default)]
|
||||
pub details: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub link: Option<String>,
|
||||
#[serde(default)]
|
||||
pub link_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub seen: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub can_respond: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub expiry_after_seen: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub require_seen: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub hide_after_seen: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResponseNotificationContent {
|
||||
pub notification_id: String,
|
||||
pub receiver_id: String,
|
||||
pub response_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationV2Payload {
|
||||
pub id: String,
|
||||
pub version: i32,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub category: String,
|
||||
pub is_system: bool,
|
||||
pub ignore_dnd: bool,
|
||||
#[serde(default)]
|
||||
pub sender_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sender_username: Option<String>,
|
||||
pub receiver_user_id: String,
|
||||
#[serde(default)]
|
||||
pub related_notifications_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub message: Option<String>,
|
||||
#[serde(default)]
|
||||
pub image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub link: Option<String>,
|
||||
#[serde(default)]
|
||||
pub link_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub responses: Vec<NotificationV2Response>,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub expiry_after_seen: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub require_seen: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub seen: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub can_delete: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationV2Response {
|
||||
#[serde(rename = "type", default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub data: Option<String>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationV2UpdatePayload {
|
||||
pub id: String,
|
||||
pub version: i32,
|
||||
#[serde(default)]
|
||||
pub updates: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationV2DeletePayload {
|
||||
#[serde(default)]
|
||||
pub ids: Vec<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// Friend events
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FriendAddContent {
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: String,
|
||||
pub user: LimitedUserFriend,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FriendDeleteContent {
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FriendUpdateContent {
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: String,
|
||||
pub user: LimitedUserFriend,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FriendOnlineContent {
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: String,
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
#[serde(default)]
|
||||
pub location: Option<String>,
|
||||
#[serde(default)]
|
||||
pub can_request_invite: Option<bool>,
|
||||
pub user: LimitedUserFriend,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FriendActiveContent {
|
||||
#[serde(rename = "userid", alias = "userId")]
|
||||
pub user_id: String,
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
pub user: LimitedUserFriend,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FriendOfflineContent {
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: String,
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FriendLocationContent {
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: String,
|
||||
pub location: String,
|
||||
#[serde(default)]
|
||||
pub traveling_to_location: Option<String>,
|
||||
#[serde(default)]
|
||||
pub world_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub can_request_invite: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub user: Option<LimitedUserFriend>,
|
||||
}
|
||||
|
||||
// User events
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserUpdateContent {
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: String,
|
||||
pub user: PipelineUserSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PipelineUserSummary {
|
||||
#[serde(default)]
|
||||
pub bio: String,
|
||||
#[serde(default)]
|
||||
pub current_avatar: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_avatar_asset_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_avatar_image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_avatar_thumbnail_image_url: Option<String>,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub fallback_avatar: Option<String>,
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub profile_pic_override: Option<String>,
|
||||
#[serde(default)]
|
||||
pub profile_pic_override_thumbnail_image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub status_description: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub user_icon: Option<String>,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserLocationContent {
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: String,
|
||||
#[serde(default)]
|
||||
pub user: Option<LimitedUserFriend>,
|
||||
pub location: String,
|
||||
#[serde(default)]
|
||||
pub instance: Option<String>,
|
||||
#[serde(default)]
|
||||
pub traveling_to_location: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserBadgeAssignedContent {
|
||||
pub badge: PipelineBadge,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PipelineBadge {
|
||||
pub badge_id: String,
|
||||
#[serde(default)]
|
||||
pub badge_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub badge_description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub badge_image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub assigned_at: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserBadgeUnassignedContent {
|
||||
pub badge_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentRefreshContent {
|
||||
pub content_type: String,
|
||||
pub file_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub item_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub item_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub action_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModifiedImageUpdateContent {
|
||||
pub file_id: String,
|
||||
pub pixel_size: i64,
|
||||
pub version_number: i64,
|
||||
pub needs_processing: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceQueueJoinedContent {
|
||||
pub instance_location: String,
|
||||
pub position: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceQueueReadyContent {
|
||||
pub instance_location: String,
|
||||
pub expiry: String,
|
||||
}
|
||||
|
||||
// Group events
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupChangedContent {
|
||||
pub group_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupMemberUpdatedContent {
|
||||
pub member: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupRoleUpdatedContent {
|
||||
pub role: Value,
|
||||
}
|
||||
|
||||
// Typed payloads for frontend events
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FriendOnlineEvent {
|
||||
pub user_id: String,
|
||||
pub user: LimitedUserFriend,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FriendOfflineEvent {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FriendUpdateEvent {
|
||||
pub user_id: String,
|
||||
pub user: LimitedUserFriend,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FriendRemovedEvent {
|
||||
pub user_id: String,
|
||||
}
|
||||
39
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "VRC Circle",
|
||||
"version": "0.0.1",
|
||||
"identifier": "cafe.kirameki.vrc-circle",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "VRC Circle",
|
||||
"width": 1200,
|
||||
"height": 800
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
64
src/App.css
Normal file
@@ -0,0 +1,64 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
background-color: #f6f6f6;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container p {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color: #f6f6f6;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
}
|
||||
129
src/App.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { ThemeProvider } from './components/theme-provider';
|
||||
import { ToasterComponent } from './components/toaster';
|
||||
import { MainLayout } from './layouts/MainLayout';
|
||||
import { Login } from './pages/Login';
|
||||
import { Verify2FA } from './pages/Verify2FA';
|
||||
import { Home } from './pages/Home';
|
||||
import { Profile } from './pages/Profile';
|
||||
import { Worlds } from './pages/Worlds';
|
||||
import { Avatars } from './pages/Avatars';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import './App.css';
|
||||
|
||||
function AppRoute({ children }: { children: React.ReactNode }) {
|
||||
const { loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <MainLayout>{children}</MainLayout>;
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vrc-circle-theme">
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ToasterComponent />
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Login />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/verify-2fa"
|
||||
element={<Verify2FA />}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<AppRoute>
|
||||
<Home />
|
||||
</AppRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<AppRoute>
|
||||
<Profile />
|
||||
</AppRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile/:userId"
|
||||
element={
|
||||
<AppRoute>
|
||||
<Profile />
|
||||
</AppRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/worlds"
|
||||
element={
|
||||
<AppRoute>
|
||||
<Worlds />
|
||||
</AppRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/avatars"
|
||||
element={
|
||||
<AppRoute>
|
||||
<Avatars />
|
||||
</AppRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<AppRoute>
|
||||
<Settings />
|
||||
</AppRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
481
src/components/AccountMenu.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { AccountService } from "@/services/account";
|
||||
import { WebSocketService } from "@/services/websocket";
|
||||
import { VRChatService } from "@/services/vrchat";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
LogOut,
|
||||
Users,
|
||||
Settings,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
IdCard,
|
||||
UserCircle,
|
||||
CircleDot,
|
||||
X,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { UserAvatar } from "@/components/UserAvatar";
|
||||
import type { UserStatus } from "@/types/bindings";
|
||||
import type { StoredAccount } from "@/types/bindings";
|
||||
import { accountsStore } from "@/stores";
|
||||
import { getStatusDotClass } from "@/lib/utils";
|
||||
|
||||
interface AccountMenuProps {
|
||||
showThemeToggle?: boolean;
|
||||
}
|
||||
|
||||
export function AccountMenu({
|
||||
showThemeToggle: _showThemeToggle = false,
|
||||
}: AccountMenuProps) {
|
||||
const { user, logout, clearLocalSession, setUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [accounts, setAccounts] = useState<StoredAccount[]>(
|
||||
accountsStore.getSnapshot() ?? []
|
||||
);
|
||||
const [switchingAccount, setSwitchingAccount] = useState<string | null>(null);
|
||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||
const [customStatusInput, setCustomStatusInput] = useState("");
|
||||
const [accountsExpanded, setAccountsExpanded] = useState(() => {
|
||||
const switchable =
|
||||
accountsStore.getSnapshot()?.filter((acc) => acc.user_id !== user?.id) ??
|
||||
[];
|
||||
return switchable.length <= 3;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = accountsStore.subscribe((value) => {
|
||||
setAccounts(value ?? []);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const switchableAccounts = accounts.filter(
|
||||
(account) => account.user_id !== user?.id
|
||||
);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAccount = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await clearLocalSession();
|
||||
setMenuOpen(false);
|
||||
navigate("/login");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewProfile = () => {
|
||||
setMenuOpen(false);
|
||||
navigate("/profile");
|
||||
};
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
setMenuOpen(false);
|
||||
navigate("/settings");
|
||||
};
|
||||
|
||||
const handleStatusChange = async (
|
||||
status: UserStatus | string,
|
||||
statusDescription: string = ""
|
||||
) => {
|
||||
if (!user || updatingStatus) return;
|
||||
|
||||
setUpdatingStatus(true);
|
||||
try {
|
||||
const updatedUser = await VRChatService.updateStatus(
|
||||
status,
|
||||
statusDescription
|
||||
);
|
||||
setUser(updatedUser);
|
||||
setCustomStatusInput("");
|
||||
} catch (error) {
|
||||
console.error("Failed to update status:", error);
|
||||
toast.error(t("component.accountMenu.statusUpdateFailed"));
|
||||
} finally {
|
||||
setUpdatingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomStatusSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (customStatusInput.trim()) {
|
||||
await handleStatusChange(
|
||||
user?.status || "active",
|
||||
customStatusInput.trim()
|
||||
);
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearStatus = () => {
|
||||
if (!user || updatingStatus) return;
|
||||
handleStatusChange(user.status ?? "active", "");
|
||||
};
|
||||
|
||||
const handleCopyStatus = async () => {
|
||||
if (!user?.statusDescription) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(user.statusDescription);
|
||||
toast.success(t("component.accountMenu.statusCopied"));
|
||||
} catch (error) {
|
||||
console.error("Failed to copy status:", error);
|
||||
toast.error(t("component.accountMenu.statusCopyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickSwitch = async (userId: string) => {
|
||||
if (switchingAccount || userId === user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSwitchingAccount(userId);
|
||||
try {
|
||||
if (user) {
|
||||
await accountsStore.saveFromUser(user);
|
||||
}
|
||||
|
||||
// Stop current WebSocket
|
||||
await WebSocketService.stop();
|
||||
|
||||
// Switch account
|
||||
const switchedUser = await AccountService.switchAccount(userId);
|
||||
setUser(switchedUser);
|
||||
|
||||
// Start WebSocket for new account
|
||||
await WebSocketService.start();
|
||||
await accountsStore.refresh();
|
||||
|
||||
if (location.pathname === "/login") {
|
||||
navigate("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to switch account", error);
|
||||
toast.error(t("component.accountMenu.accountSwitchFailed"));
|
||||
} finally {
|
||||
setSwitchingAccount(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="default"
|
||||
className="rounded-full h-10 px-2 pr-3 gap-2 flex items-center"
|
||||
>
|
||||
{user ? (
|
||||
<UserAvatar
|
||||
user={user}
|
||||
className="h-8 w-8"
|
||||
imageClassName="object-cover"
|
||||
fallbackClassName="text-xs font-medium bg-muted/50"
|
||||
statusClassName={getStatusDotClass(user)}
|
||||
statusSize="45%"
|
||||
statusOffset="-12%"
|
||||
statusContainerClassName="bg-background/90 shadow-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<UserCircle className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
{user && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 flex items-center gap-3 group">
|
||||
<div
|
||||
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer rounded-sm -mx-2 px-2 hover:bg-accent transition-colors"
|
||||
onClick={handleViewProfile}
|
||||
>
|
||||
<UserAvatar
|
||||
user={user}
|
||||
className="h-9 w-9"
|
||||
imageClassName="object-cover"
|
||||
fallbackClassName="text-sm font-medium bg-muted/50"
|
||||
statusClassName={getStatusDotClass(user)}
|
||||
statusSize="45%"
|
||||
statusOffset="-10%"
|
||||
statusContainerClassName="bg-background/90 shadow-sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{user.displayName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
@{user.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 shrink-0 text-red-500 hover:text-red-500"
|
||||
title={t("component.accountMenu.logout")}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<>
|
||||
{user.statusDescription && (
|
||||
<div className="group relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||
<span className="truncate flex-1" onClick={handleCopyStatus}>
|
||||
{user.statusDescription}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-2 inline-flex size-5 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearStatus();
|
||||
}}
|
||||
title={t("component.accountMenu.clearStatus")}
|
||||
aria-label={t("component.accountMenu.clearStatus")}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
disabled={updatingStatus}
|
||||
className="[&>svg:last-child]:mr-2"
|
||||
>
|
||||
<CircleDot className="h-4 w-4" />
|
||||
<span>{t("component.accountMenu.setStatus")}</span>
|
||||
{updatingStatus && (
|
||||
<Loader2 className="h-3 w-3 ml-auto animate-spin" />
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-56">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleStatusChange("active", user.statusDescription ?? "")
|
||||
}
|
||||
disabled={updatingStatus}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-transparent ring-2 ring-emerald-500 ring-inset" />
|
||||
<span>{t("common.status.active")}</span>
|
||||
</div>
|
||||
{user.status === "active" && (
|
||||
<CheckCircle2 className="h-4 w-4 ml-auto text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleStatusChange("join me", user.statusDescription ?? "")
|
||||
}
|
||||
disabled={updatingStatus}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-sky-500" />
|
||||
<span>{t("common.status.joinMe")}</span>
|
||||
</div>
|
||||
{user.status === "join me" && (
|
||||
<CheckCircle2 className="h-4 w-4 ml-auto text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleStatusChange("ask me", user.statusDescription ?? "")
|
||||
}
|
||||
disabled={updatingStatus}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span>{t("common.status.askMe")}</span>
|
||||
</div>
|
||||
{user.status === "ask me" && (
|
||||
<CheckCircle2 className="h-4 w-4 ml-auto text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleStatusChange("busy", user.statusDescription ?? "")
|
||||
}
|
||||
disabled={updatingStatus}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500" />
|
||||
<span>{t("common.status.busy")}</span>
|
||||
</div>
|
||||
{user.status === "busy" && (
|
||||
<CheckCircle2 className="h-4 w-4 ml-auto text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-2">
|
||||
<form
|
||||
onSubmit={handleCustomStatusSubmit}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t(
|
||||
"component.accountMenu.customStatusPlaceholder"
|
||||
)}
|
||||
value={customStatusInput}
|
||||
onChange={(e) => setCustomStatusInput(e.target.value)}
|
||||
disabled={updatingStatus}
|
||||
className="h-8 text-sm"
|
||||
maxLength={32}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!customStatusInput.trim() || updatingStatus}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{t("component.accountMenu.setCustomStatus")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuItem onClick={handleViewProfile}>
|
||||
<IdCard className="h-4 w-4" />
|
||||
<span>{t("component.accountMenu.viewProfile")}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm font-semibold hover:bg-accent rounded-sm"
|
||||
onClick={() => setAccountsExpanded(!accountsExpanded)}
|
||||
>
|
||||
<span className="flex-1">
|
||||
{t("component.accountMenu.switchAccount")}
|
||||
</span>
|
||||
{accountsExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
</div>
|
||||
{accountsExpanded &&
|
||||
switchableAccounts.map((account) => {
|
||||
const isCurrent = account.user_id === user?.id;
|
||||
const isSwitching = switchingAccount === account.user_id;
|
||||
const disabled =
|
||||
isCurrent || (switchingAccount !== null && !isSwitching);
|
||||
const primaryAvatar =
|
||||
account.avatar_url ?? account.avatar_fallback_url ?? undefined;
|
||||
const fallbackAvatar =
|
||||
account.avatar_fallback_url ?? account.avatar_url ?? undefined;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={account.user_id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleQuickSwitch(account.user_id);
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<UserAvatar
|
||||
user={{
|
||||
displayName: account.display_name,
|
||||
userIcon: primaryAvatar,
|
||||
profilePicOverride: primaryAvatar,
|
||||
profilePicOverrideThumbnail: primaryAvatar,
|
||||
currentAvatarImageUrl: fallbackAvatar,
|
||||
currentAvatarThumbnailImageUrl: fallbackAvatar,
|
||||
}}
|
||||
className="h-8 w-8"
|
||||
fallbackClassName="text-xs font-medium"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col flex-1">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{account.display_name ||
|
||||
t("component.accountMenu.unknownUser")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
@
|
||||
{account.username ||
|
||||
t("component.accountMenu.unknownUsername")}
|
||||
</span>
|
||||
</div>
|
||||
{isCurrent ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
|
||||
) : isSwitching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{accountsExpanded && (
|
||||
<DropdownMenuItem onClick={handleAddAccount} disabled={isLoading}>
|
||||
<div className="h-8 w-8 shrink-0 flex items-center justify-center rounded-full bg-muted">
|
||||
<Users className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("component.accountMenu.addAccount")}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleOpenSettings}>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>{t("component.accountMenu.settings")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
101
src/components/AlertBarContainer.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { AlertBar } from "./ui/alert-bar";
|
||||
import { alertStore } from "@/stores";
|
||||
import type { AlertStoreState } from "@/stores/alert-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AlertBarContainer() {
|
||||
// Force re-render every 30 seconds
|
||||
// TODO: Remove this, no longer needed
|
||||
const [, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(Date.now()), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
const [alertState, setAlertState] = useState<AlertStoreState>(
|
||||
alertStore.getSnapshot()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = alertStore.subscribe((state) => {
|
||||
setAlertState(state);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const { alerts, currentIndex } = alertState;
|
||||
const currentAlert = alerts[currentIndex];
|
||||
|
||||
if (!currentAlert) return null;
|
||||
|
||||
const hasMultipleAlerts = alerts.length > 1;
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (currentAlert.dismissable) {
|
||||
alertStore.removeAlert(currentAlert.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = async () => {
|
||||
if (currentAlert.onClick) {
|
||||
try {
|
||||
await currentAlert.onClick();
|
||||
} catch (error) {
|
||||
console.error("Alert onClick error:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex-shrink-0 animate-slide-in-from-top">
|
||||
<AlertBar
|
||||
variant={currentAlert.variant}
|
||||
dismissable={currentAlert.dismissable}
|
||||
onDismiss={handleDismiss}
|
||||
className={cn(
|
||||
"w-full rounded-none border-0 border-b transition-opacity",
|
||||
currentAlert.onClick && "cursor-pointer hover:opacity-90"
|
||||
)}
|
||||
onClick={currentAlert.onClick ? handleClick : undefined}
|
||||
>
|
||||
<div className="flex items-center flex-1 min-w-0 gap-2">
|
||||
{/* Alert Message */}
|
||||
<div className="flex-1 min-w-0">{currentAlert.message}</div>
|
||||
|
||||
{/* Navigation Controls (Only show if there are multiple alerts) */}
|
||||
{hasMultipleAlerts && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-4">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
alertStore.previousAlert();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
aria-label="Previous alert"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<span className="text-xs font-mono px-2">
|
||||
{currentIndex + 1}/{alerts.length}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
alertStore.nextAlert();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
aria-label="Next alert"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AlertBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/CachedImage.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCachedImage } from '@/hooks/useCachedImage';
|
||||
import type { ImgHTMLAttributes } from 'react';
|
||||
|
||||
type CachedImageProps = ImgHTMLAttributes<HTMLImageElement> & {
|
||||
src: string | null | undefined;
|
||||
};
|
||||
|
||||
export function CachedImage({ src, className, ...rest }: CachedImageProps) {
|
||||
const cachedSrc = useCachedImage(src);
|
||||
|
||||
if (!src && !cachedSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
{...rest}
|
||||
src={cachedSrc ?? src ?? undefined}
|
||||
className={className ? cn(className) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
194
src/components/DevTools.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { ChevronUp, ChevronDown } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { StoreStudio } from "@/pages/StoreStudio";
|
||||
import { DatabaseStudio } from "@/pages/DatabaseStudio";
|
||||
import { Logs } from "@/pages/Logs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { developerModeStore } from "@/stores/developer-mode-store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function DevTools() {
|
||||
const { t } = useTranslation();
|
||||
const [isDeveloperMode, setIsDeveloperMode] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [height, setHeight] = useState(400);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [shouldRenderContent, setShouldRenderContent] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState(48);
|
||||
|
||||
// TODO: Refactor this animation duration to a shared constant or something
|
||||
// Keep this in sync with the tailwind duration used in classes below
|
||||
const ANIMATION_DURATION = 300;
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = developerModeStore.subscribe(setIsDeveloperMode);
|
||||
developerModeStore.ensure();
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Handle expanded content rendering with animation delay
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
setShouldRenderContent(true);
|
||||
} else {
|
||||
// Delay unmounting to allow exit animation + height transition to finish
|
||||
const timeout = setTimeout(
|
||||
() => setShouldRenderContent(false),
|
||||
ANIMATION_DURATION + 20
|
||||
);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
// Measure the header height so we can animate between header-only (collapsed)
|
||||
// and the expanded panel height. Use ResizeObserver to update if styles change.
|
||||
useEffect(() => {
|
||||
const el = headerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const update = () => setHeaderHeight(el.offsetHeight || 48);
|
||||
update();
|
||||
|
||||
let ro: ResizeObserver | undefined;
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
} else {
|
||||
// Fallback if ResizeObserver somehow isn't supported
|
||||
window.addEventListener("resize", update);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (ro) ro.disconnect();
|
||||
else window.removeEventListener("resize", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const newHeight = window.innerHeight - e.clientY;
|
||||
const minHeight = 100;
|
||||
const maxHeight = window.innerHeight - 100;
|
||||
setHeight(Math.max(minHeight, Math.min(maxHeight, newHeight)));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
// Don't render if developer mode is disabled
|
||||
if (!isDeveloperMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"fixed bottom-0 left-0 right-0 z-50 overflow-hidden",
|
||||
!isResizing && "transition-all duration-300 ease-in-out",
|
||||
isResizing && "select-none"
|
||||
)}
|
||||
style={{
|
||||
// We set explicit height so the drag feels immediate for now
|
||||
height: isResizing ? `${height}px` : undefined,
|
||||
maxHeight: `${isExpanded ? height : headerHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex flex-col",
|
||||
isExpanded && "bg-background/95 backdrop-blur-md border-t shadow-2xl"
|
||||
)}
|
||||
>
|
||||
{/* Resize handle (only visible when expanded) */}
|
||||
{isExpanded && (
|
||||
<div
|
||||
className="h-1 cursor-ns-resize hover:bg-primary/20 active:bg-primary/40 transition-colors"
|
||||
onMouseDown={() => setIsResizing(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toggle bar (The pull up tab) */}
|
||||
<div
|
||||
ref={headerRef}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-accent/50 transition-colors flex items-center justify-center",
|
||||
isExpanded
|
||||
? "px-4 py-2 border-b"
|
||||
: "mx-auto w-64 px-4 py-1.5 rounded-t-lg bg-background/80 backdrop-blur-sm border border-b-0 shadow-lg"
|
||||
)}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 transition-transform duration-300" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 transition-transform duration-300" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{t("layout.sidebar.developerTools")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{shouldRenderContent && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex flex-col overflow-hidden",
|
||||
isExpanded ? "animate-fade-in" : "animate-fade-out"
|
||||
)}
|
||||
>
|
||||
<Tabs
|
||||
defaultValue="logger"
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-4">
|
||||
<TabsTrigger value="logger">
|
||||
{t("layout.developerTools.tabs.logger")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="database-studio">
|
||||
{t("layout.developerTools.tabs.databaseStudio")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="store-studio">
|
||||
{t("layout.developerTools.tabs.storeStudio")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<TabsContent value="logger" className="m-0 h-full">
|
||||
<Logs />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="database-studio" className="m-0 h-full">
|
||||
<DatabaseStudio />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="store-studio" className="m-0 h-full">
|
||||
<StoreStudio heading={false} />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
433
src/components/FriendsSidebar.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn, isUserOffline, getStatusDotClass } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Users, ChevronLeft, User, Loader2 } from "lucide-react";
|
||||
import { UserAvatar } from "@/components/UserAvatar";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { userStore } from "@/stores";
|
||||
import type { LimitedUserFriend, UserStatus } from "@/types/bindings";
|
||||
|
||||
const POLL_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface FriendsSidebarProps {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function FriendsSidebar({ isOpen, onToggle }: FriendsSidebarProps) {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [friends, setFriends] = useState<LimitedUserFriend[] | null>(
|
||||
userStore.getFriendsSnapshot()
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<"loadFailed" | null>(null);
|
||||
|
||||
const describeLocation = useCallback(
|
||||
(location: string): string => {
|
||||
const normalized = location.toLowerCase();
|
||||
|
||||
if (!location || normalized === "offline") {
|
||||
return t("layout.friendsSidebar.location.offline");
|
||||
}
|
||||
|
||||
if (normalized === "private") {
|
||||
return t("layout.friendsSidebar.location.private");
|
||||
}
|
||||
|
||||
if (normalized.startsWith("traveling")) {
|
||||
return t("layout.friendsSidebar.location.traveling");
|
||||
}
|
||||
|
||||
if (normalized.startsWith("group:")) {
|
||||
return t("layout.friendsSidebar.location.group");
|
||||
}
|
||||
|
||||
if (normalized.includes(":")) {
|
||||
return t("layout.friendsSidebar.location.instance");
|
||||
}
|
||||
|
||||
return location;
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const getStatusText = useCallback(
|
||||
(status?: UserStatus | null) => {
|
||||
if (!status) {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case "active":
|
||||
return t("common.status.active");
|
||||
case "join me":
|
||||
return t("common.status.joinMe");
|
||||
case "ask me":
|
||||
return t("common.status.askMe");
|
||||
case "busy":
|
||||
return t("common.status.busy");
|
||||
}
|
||||
|
||||
return status;
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const handleUpdate = (value: LimitedUserFriend[] | null) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setFriends(value);
|
||||
setLoading(false);
|
||||
if (value) {
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = userStore.subscribeFriends(handleUpdate);
|
||||
|
||||
if (!user) {
|
||||
setFriends(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
|
||||
// Only show loading if we don't already have data
|
||||
const snapshot = userStore.getFriendsSnapshot();
|
||||
if (!snapshot) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
userStore.ensureFriends().catch((err) => {
|
||||
console.error("Failed to load friends:", err);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setError("loadFailed");
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
// Periodic refresh from backend (backend handles WebSocket updates to UserStore)
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const refreshFriends = async (showLoading = true) => {
|
||||
// Don't show loading spinner if we already have data
|
||||
const hasData = userStore.getFriendsSnapshot() !== null;
|
||||
const shouldShowLoading = showLoading && !hasData;
|
||||
|
||||
if (!disposed && shouldShowLoading) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
} else if (!disposed) {
|
||||
setError(null);
|
||||
}
|
||||
try {
|
||||
await userStore.refreshFriends();
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh friends:", err);
|
||||
if (!disposed) {
|
||||
setError("loadFailed");
|
||||
if (shouldShowLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!disposed && shouldShowLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load
|
||||
void refreshFriends(true);
|
||||
|
||||
// Poll periodically to sync with backend UserStore
|
||||
const poller = setInterval(() => {
|
||||
void refreshFriends(false);
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearInterval(poller);
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
const getFriendSecondaryText = (friend: LimitedUserFriend) => {
|
||||
const location = friend.location?.trim();
|
||||
if (location && location.toLowerCase() !== "offline") {
|
||||
return describeLocation(location);
|
||||
}
|
||||
if (friend.platform?.toLowerCase() === "web") {
|
||||
return t("layout.friendsSidebar.status.website");
|
||||
}
|
||||
if (friend.statusDescription) {
|
||||
return friend.statusDescription;
|
||||
}
|
||||
return getStatusText(friend.status);
|
||||
};
|
||||
|
||||
const friendsList = friends ?? [];
|
||||
|
||||
const { sections, onlineCount, compactFriends } = useMemo(() => {
|
||||
const inWorld: LimitedUserFriend[] = [];
|
||||
const active: LimitedUserFriend[] = [];
|
||||
const offline: LimitedUserFriend[] = [];
|
||||
|
||||
const isInWorld = (friend: LimitedUserFriend) => {
|
||||
const location = friend.location?.toLowerCase() ?? "";
|
||||
if (!location || location === "offline") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const friend of friendsList) {
|
||||
if (isInWorld(friend)) {
|
||||
inWorld.push(friend);
|
||||
} else if (!isUserOffline(friend)) {
|
||||
active.push(friend);
|
||||
} else {
|
||||
offline.push(friend);
|
||||
}
|
||||
}
|
||||
|
||||
const sectionsData = [
|
||||
{
|
||||
key: "in-world",
|
||||
title: t("layout.friendsSidebar.sections.inWorld.title"),
|
||||
friends: inWorld,
|
||||
emptyMessage: t("layout.friendsSidebar.sections.inWorld.empty"),
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
title: t("layout.friendsSidebar.sections.active.title"),
|
||||
friends: active,
|
||||
emptyMessage: t("layout.friendsSidebar.sections.active.empty"),
|
||||
},
|
||||
{
|
||||
key: "offline",
|
||||
title: t("layout.friendsSidebar.sections.offline.title"),
|
||||
friends: offline,
|
||||
emptyMessage: t("layout.friendsSidebar.sections.offline.empty"),
|
||||
},
|
||||
];
|
||||
|
||||
const online = inWorld.length + active.length;
|
||||
// For the collapsed view we want to show all friends (in-world, active, then offline)
|
||||
// so the compact list should include offline friends as well.
|
||||
const compactList = [...inWorld, ...active, ...offline];
|
||||
|
||||
return {
|
||||
sections: sectionsData,
|
||||
onlineCount: online,
|
||||
compactFriends: compactList,
|
||||
};
|
||||
}, [friendsList, t]);
|
||||
|
||||
const renderFriendRow = (friend: LimitedUserFriend) => (
|
||||
<button
|
||||
key={friend.id}
|
||||
type="button"
|
||||
onClick={() => navigate(`/profile/${friend.id}`)}
|
||||
className="w-full rounded-lg border border-border/70 bg-card/60 px-2 py-1.5 text-left transition-all duration-200 hover:border-border hover:bg-card/80 hover:scale-[1.02] active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<UserAvatar
|
||||
user={{
|
||||
displayName: friend.displayName,
|
||||
userIcon: friend.userIcon,
|
||||
profilePicOverride: friend.profilePicOverride,
|
||||
profilePicOverrideThumbnail: friend.profilePicOverrideThumbnail,
|
||||
currentAvatarImageUrl: friend.currentAvatarImageUrl,
|
||||
currentAvatarThumbnailImageUrl:
|
||||
friend.currentAvatarThumbnailImageUrl,
|
||||
}}
|
||||
className="h-8 w-8"
|
||||
imageClassName="object-cover"
|
||||
fallbackClassName="text-xs font-medium bg-muted/50"
|
||||
statusClassName={getStatusDotClass(friend)}
|
||||
statusSize="45%"
|
||||
statusOffset="-10%"
|
||||
statusContainerClassName="bg-background/90 shadow-sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-tight truncate">
|
||||
{friend.displayName}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground truncate">
|
||||
{getFriendSecondaryText(friend)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Show all compact friends when collapsed (no slicing).
|
||||
const compactFriendsToShow = compactFriends;
|
||||
// Only show sections that actually contain friends in the expanded view
|
||||
const visibleSections = sections.filter((s) => s.friends.length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full bg-card border-l border-border transition-all duration-300 ease-in-out flex flex-col flex-shrink-0 overflow-hidden",
|
||||
isOpen ? "w-64" : "w-16"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-16 border-b border-border flex items-center justify-between px-4">
|
||||
{isOpen && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
<span className="text-sm font-semibold">
|
||||
{t("layout.friendsSidebar.title")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggle}
|
||||
className="h-8 w-8"
|
||||
title={
|
||||
isOpen
|
||||
? t("layout.friendsSidebar.collapse")
|
||||
: t("layout.friendsSidebar.expand")
|
||||
}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<User className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Friends List (uses custom ScrollArea to avoid native scrollbar) */}
|
||||
<ScrollArea className="flex-1 p-3">
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-xs text-destructive text-center py-8">
|
||||
{t(`layout.friendsSidebar.errors.${error}`)}
|
||||
</p>
|
||||
) : isOpen ? (
|
||||
visibleSections.length > 0 ? (
|
||||
visibleSections.map((section, index) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className={cn(
|
||||
"space-y-1.5",
|
||||
index > 0 && "pt-4 border-t border-border/60"
|
||||
)}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{section.title}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{section.friends.map((friend) => renderFriendRow(friend))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-8">
|
||||
{t("layout.friendsSidebar.empty.collapsed")}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
// Collapsed view - show avatars only with separators between sections
|
||||
<div className="space-y-1">
|
||||
{compactFriendsToShow.length > 0 ? (
|
||||
<>
|
||||
{sections.map((section, sectionIndex) => {
|
||||
if (section.friends.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={section.key}>
|
||||
{sectionIndex > 0 && (
|
||||
<div className="py-1">
|
||||
<div className="h-px bg-border/60" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{section.friends.map((friend) => (
|
||||
<button
|
||||
key={friend.id}
|
||||
type="button"
|
||||
onClick={() => navigate(`/profile/${friend.id}`)}
|
||||
title={friend.displayName}
|
||||
className="mx-auto block rounded-full transition-transform duration-200 hover:scale-110 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
>
|
||||
<UserAvatar
|
||||
user={{
|
||||
displayName: friend.displayName,
|
||||
userIcon: friend.userIcon,
|
||||
profilePicOverride: friend.profilePicOverride,
|
||||
profilePicOverrideThumbnail:
|
||||
friend.profilePicOverrideThumbnail,
|
||||
currentAvatarImageUrl:
|
||||
friend.currentAvatarImageUrl,
|
||||
currentAvatarThumbnailImageUrl:
|
||||
friend.currentAvatarThumbnailImageUrl,
|
||||
}}
|
||||
className="h-8 w-8 mx-auto"
|
||||
imageClassName="object-cover"
|
||||
fallbackClassName="text-xs font-medium bg-muted/50"
|
||||
statusClassName={getStatusDotClass(friend)}
|
||||
statusSize="45%"
|
||||
statusOffset="-10%"
|
||||
statusContainerClassName="bg-background/90 shadow-sm"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-6">
|
||||
{t("layout.friendsSidebar.empty.collapsed")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer - Online count */}
|
||||
{isOpen && !loading && (
|
||||
<div className="border-t border-border p-3 text-center text-xs text-muted-foreground">
|
||||
{t("layout.friendsSidebar.onlineCount", { count: onlineCount })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/LoginRequired.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Lock, LogIn } from "lucide-react";
|
||||
|
||||
export function LoginRequired() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card className="border-dashed">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-muted flex items-center justify-center">
|
||||
<Lock className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">
|
||||
{t("component.loginRequired.title")}
|
||||
</CardTitle>
|
||||
{/* <CardDescription>
|
||||
</CardDescription> */}
|
||||
</CardHeader>
|
||||
<CardContent className="text-center space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("component.loginRequired.bottomText")}
|
||||
</p>
|
||||
<Button onClick={() => navigate("/login")} size="lg">
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
{t("component.loginRequired.signIn")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/components/Navbar.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Menu } from "lucide-react";
|
||||
import { AccountMenu } from "@/components/AccountMenu";
|
||||
|
||||
interface NavbarProps {
|
||||
onMenuToggle: () => void;
|
||||
}
|
||||
|
||||
export function Navbar({ onMenuToggle }: NavbarProps) {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="h-16 bg-card border-b border-border flex items-center justify-between px-6">
|
||||
{/* Left */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onMenuToggle}
|
||||
className="lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Center */}
|
||||
<div className="flex-1 flex justify-center"></div>
|
||||
|
||||
{/* Right */}
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
126
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { LucideIcon, Menu, ChevronLeft } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
mainItems: NavigationItem[];
|
||||
bottomItems: NavigationItem[];
|
||||
isActive: (path: string) => boolean;
|
||||
onNavigate: (path: string) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
isOpen,
|
||||
onToggle,
|
||||
mainItems,
|
||||
bottomItems,
|
||||
isActive,
|
||||
onNavigate,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"h-full bg-card border-r border-border transition-[width] duration-300 ease-in-out flex flex-col flex-shrink-0 overflow-hidden",
|
||||
isOpen ? "w-56" : "w-16"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center h-16 border-b border-border px-4 w-full",
|
||||
isOpen ? "justify-between gap-2" : "justify-center"
|
||||
)}
|
||||
>
|
||||
{isOpen && (
|
||||
<span className="font-bold text-lg whitespace-nowrap overflow-hidden text-ellipsis transition-opacity duration-200 ease-in-out">
|
||||
{t("layout.sidebar.appName")}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggle}
|
||||
className="shrink-0 transition-transform duration-300 ease-in-out"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
) : (
|
||||
<Menu className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Navigation Items */}
|
||||
<nav className="flex-1 flex flex-col gap-2 p-3">
|
||||
{mainItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.path);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant={active ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"transition-colors h-10",
|
||||
!isOpen && "w-10 px-0 flex items-center justify-center",
|
||||
isOpen && "w-full justify-start px-3",
|
||||
active && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
onClick={() => onNavigate(item.path)}
|
||||
title={!isOpen ? item.label : undefined}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{isOpen && (
|
||||
<span className="ml-3 truncate text-sm">{item.label}</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom Navigation Items */}
|
||||
{bottomItems.length > 0 && (
|
||||
<div className="border-t border-border p-3">
|
||||
<nav className="flex flex-col gap-2">
|
||||
{bottomItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.path);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant={active ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"transition-colors h-10",
|
||||
!isOpen && "w-10 px-0 flex items-center justify-center",
|
||||
isOpen && "w-full justify-start px-3",
|
||||
active && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
onClick={() => onNavigate(item.path)}
|
||||
title={!isOpen ? item.label : undefined}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{isOpen && (
|
||||
<span className="ml-3 truncate text-sm">{item.label}</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
90
src/components/UserAvatar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getUserAvatarUrl, getUserInitials } from "@/lib/user";
|
||||
import type { User } from "@/types/bindings";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCachedImage } from "@/hooks/useCachedImage";
|
||||
|
||||
type MinimalUser = Pick<
|
||||
User,
|
||||
| "displayName"
|
||||
| "userIcon"
|
||||
| "profilePicOverride"
|
||||
| "profilePicOverrideThumbnail"
|
||||
| "currentAvatarImageUrl"
|
||||
| "currentAvatarThumbnailImageUrl"
|
||||
>;
|
||||
|
||||
interface UserAvatarProps {
|
||||
user: MinimalUser;
|
||||
className?: string;
|
||||
imageClassName?: string;
|
||||
fallbackClassName?: string;
|
||||
fallbackText?: string;
|
||||
statusClassName?: string;
|
||||
avatarClassName?: string;
|
||||
statusSize?: string;
|
||||
statusOffset?: string;
|
||||
statusContainerClassName?: string;
|
||||
}
|
||||
|
||||
export function UserAvatar({
|
||||
user,
|
||||
className,
|
||||
imageClassName,
|
||||
fallbackClassName,
|
||||
fallbackText,
|
||||
statusClassName,
|
||||
avatarClassName,
|
||||
statusSize,
|
||||
statusOffset,
|
||||
statusContainerClassName,
|
||||
}: UserAvatarProps) {
|
||||
const avatarUrl = getUserAvatarUrl(user);
|
||||
const cachedSrc = useCachedImage(avatarUrl);
|
||||
const displaySrc = cachedSrc ?? undefined;
|
||||
const initials = fallbackText ?? getUserInitials(user.displayName);
|
||||
const indicatorSize = statusSize ?? "38%";
|
||||
const indicatorOffset = statusOffset ?? "4%";
|
||||
const indicatorStyle: CSSProperties = {
|
||||
width: indicatorSize,
|
||||
height: indicatorSize,
|
||||
bottom: indicatorOffset,
|
||||
right: indicatorOffset,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative inline-flex", className)}>
|
||||
<Avatar className={cn("h-full w-full", avatarClassName)}>
|
||||
{displaySrc ? (
|
||||
<AvatarImage
|
||||
src={displaySrc}
|
||||
alt={user.displayName ?? "User avatar"}
|
||||
className={cn("object-cover", imageClassName)}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback
|
||||
className={cn("text-sm font-medium uppercase", fallbackClassName)}
|
||||
>
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{statusClassName ? (
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none absolute flex items-center justify-center rounded-full bg-background shadow-sm",
|
||||
statusContainerClassName
|
||||
)}
|
||||
style={indicatorStyle}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block h-[68%] w-[68%] rounded-full",
|
||||
statusClassName
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
src/components/store-studio/StoreEditor.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
// TODO: Localize all strings
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { Save, X, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface StoreEditorProps {
|
||||
storeName: string;
|
||||
initialValue: unknown;
|
||||
onSave: (value: unknown) => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
scopeLabel?: string;
|
||||
}
|
||||
|
||||
export function StoreEditor({
|
||||
storeName,
|
||||
initialValue,
|
||||
onSave,
|
||||
onCancel,
|
||||
scopeLabel,
|
||||
}: StoreEditorProps) {
|
||||
const [jsonText, setJsonText] = useState(() =>
|
||||
JSON.stringify(initialValue, null, 2)
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
|
||||
await onSave(parsed);
|
||||
toast.success(`${storeName} saved successfully`);
|
||||
|
||||
onCancel();
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
setError(`Invalid JSON: ${err.message}`);
|
||||
toast.error("Invalid JSON");
|
||||
} else {
|
||||
setError(
|
||||
`Failed to save: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`
|
||||
);
|
||||
toast.error("Save failed");
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = () => {
|
||||
try {
|
||||
JSON.parse(jsonText);
|
||||
setError(null);
|
||||
toast.success("Valid JSON");
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
setError(`Invalid JSON: ${err.message}`);
|
||||
toast.error("Invalid JSON");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-yellow-400 dark:border-yellow-600">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||
{`${storeName} Editor`} {scopeLabel ? `(${scopeLabel})` : ""}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Edit the JSON for this store and save your changes.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
className="font-mono text-xs min-h-[300px] max-h-[60vh]"
|
||||
placeholder={"Paste JSON here..."}
|
||||
/>
|
||||
{error && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded border border-destructive/30">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} disabled={saving || !!error} size="sm">
|
||||
{saving ? (
|
||||
<>Saving...</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={handleValidate} variant="outline" size="sm">
|
||||
Validate
|
||||
</Button>
|
||||
<Button onClick={onCancel} variant="ghost" size="sm">
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-xs text-yellow-900 dark:text-yellow-200">
|
||||
<strong>Warning:</strong> Editing store data can break the app if you
|
||||
save invalid JSON. Proceed with caution.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
64
src/components/store-studio/StoreStudioError.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// TODO: Localize all strings
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface StoreStudioErrorProps {
|
||||
storeName?: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function StoreStudioError({
|
||||
storeName,
|
||||
onRetry,
|
||||
}: StoreStudioErrorProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-6 bg-background">
|
||||
<Card className="max-w-2xl w-full border-destructive">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-8 w-8 text-destructive" />
|
||||
<div>
|
||||
<CardTitle className="text-2xl">Connection error</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Can't attach to main window stores.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{storeName ? (
|
||||
<>
|
||||
Couldn't find <strong>{storeName}</strong> in the main window.
|
||||
</>
|
||||
) : (
|
||||
<>Store Studio couldn't attach to the main window.</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ensure the main app is open and logged in, then retry.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{onRetry && (
|
||||
<Button onClick={onRetry} variant="default">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
src/components/store-studio/StoreViewer.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
// TODO: Localize all strings
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Loader2, Edit, Clock, Database } from "lucide-react";
|
||||
import type {
|
||||
StoreEntry,
|
||||
StoreAction,
|
||||
StoreDebugData,
|
||||
} from "@/types/store-studio";
|
||||
import { StoreEditor } from "./StoreEditor";
|
||||
|
||||
interface StoreViewerProps<T = unknown> {
|
||||
store: StoreEntry<T>;
|
||||
}
|
||||
|
||||
function formatAge(ageMs: number | null): string {
|
||||
if (ageMs === null) {
|
||||
return "never";
|
||||
}
|
||||
const seconds = Math.floor(ageMs / 1000);
|
||||
if (seconds < 1) return "<1s";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
export function StoreViewer<T = unknown>({ store }: StoreViewerProps<T>) {
|
||||
console.log("[StoreViewer] Component rendering for store:", store.label);
|
||||
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [debugInfo, setDebugInfo] = useState<StoreDebugData | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Subscribe to store changes
|
||||
useEffect(() => {
|
||||
console.log(`[StoreViewer] ${store.label} setting up subscription`);
|
||||
|
||||
const unsubscribe = store.subscribe((value) => {
|
||||
console.log(
|
||||
`[StoreViewer] ${store.label} received update via subscription:`,
|
||||
value !== null ? "(data)" : "null"
|
||||
);
|
||||
setData(value);
|
||||
if (store.debugInfo) {
|
||||
setDebugInfo(store.debugInfo());
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log(`[StoreViewer] ${store.label} cleaning up subscription`);
|
||||
unsubscribe();
|
||||
};
|
||||
}, [store]);
|
||||
|
||||
// Refresh debug info every second to update age display
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (store.debugInfo) {
|
||||
setDebugInfo(store.debugInfo());
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [store]);
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = async (newValue: unknown) => {
|
||||
if (store.setData) {
|
||||
await store.setData(newValue as T);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
if (isEditing && store.canEdit && store.setData) {
|
||||
return (
|
||||
<StoreEditor
|
||||
storeName={store.label}
|
||||
initialValue={data}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const showEditButton = store.canEdit && store.setData;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
{store.label}
|
||||
</CardTitle>
|
||||
{store.description && (
|
||||
<CardDescription className="mt-2">
|
||||
{store.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
{showEditButton && (
|
||||
<Button onClick={handleEdit} variant="outline" size="sm">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{debugInfo && (
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm">
|
||||
{debugInfo.updatedAt !== undefined &&
|
||||
debugInfo.updatedAt !== null && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>
|
||||
Updated:{" "}
|
||||
{debugInfo.updatedAt
|
||||
? new Date(debugInfo.updatedAt).toLocaleString()
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{debugInfo.ageMs !== undefined && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span>Age: {formatAge(debugInfo.ageMs)}</span>
|
||||
</div>
|
||||
)}
|
||||
{debugInfo.stale !== undefined && (
|
||||
<Badge variant={debugInfo.stale ? "destructive" : "secondary"}>
|
||||
{debugInfo.stale ? "Stale" : "Fresh"}
|
||||
</Badge>
|
||||
)}
|
||||
{debugInfo.inflight && (
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{store.actions && store.actions.length > 0 && (
|
||||
<CardContent className="border-t pt-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{store.actions.map((action) => (
|
||||
<ActionButton key={action.id} action={action} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Data</CardTitle>
|
||||
<CardDescription>
|
||||
Real-time view of store content (updates instantly via
|
||||
subscriptions)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[60vh] rounded border bg-muted/40">
|
||||
<pre className="p-4 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{(() => {
|
||||
if (data === null) {
|
||||
return "<null>";
|
||||
}
|
||||
if (data === undefined) {
|
||||
return "<undefined>";
|
||||
}
|
||||
if (Array.isArray(data) && data.length === 0) {
|
||||
return "[] (empty array)";
|
||||
}
|
||||
return JSON.stringify(data, null, 2);
|
||||
})()}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
action: StoreAction;
|
||||
}
|
||||
|
||||
function ActionButton({ action }: ActionButtonProps) {
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
await action.onClick();
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
variant={action.variant || "outline"}
|
||||
size="sm"
|
||||
disabled={action.disabled || pending}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
action.icon
|
||||
)}
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
82
src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vrc-circle-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
|
||||
// Listen for system theme changes
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.add(e.matches ? "dark" : "light")
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange)
|
||||
return () => mediaQuery.removeEventListener("change", handleChange)
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
|
||||
return context
|
||||
}
|
||||
39
src/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">{t('component.themeSwitcher.toggle')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
{t('component.themeSwitcher.light')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
{t('component.themeSwitcher.dark')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
{t('component.themeSwitcher.system')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
12
src/components/toaster.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Toaster } from 'sonner';
|
||||
|
||||
export function ToasterComponent() {
|
||||
return (
|
||||
<Toaster
|
||||
theme="dark"
|
||||
position="bottom-right"
|
||||
expand
|
||||
richColors
|
||||
/>
|
||||
);
|
||||
}
|
||||
95
src/components/ui/alert-bar.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X, AlertTriangle, AlertCircle, Info } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertBarVariants = cva(
|
||||
"relative w-full flex items-center gap-3 px-4 py-3 text-sm font-medium",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
info: "bg-blue-500/10 text-blue-700 dark:text-blue-400 border-l-4 border-blue-500",
|
||||
warning:
|
||||
"bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 border-l-4 border-yellow-500",
|
||||
error:
|
||||
"bg-red-500/10 text-red-700 dark:text-red-400 border-l-4 border-red-500",
|
||||
critical:
|
||||
"bg-red-600/20 text-red-800 dark:text-red-300 border-l-4 border-red-600",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "info",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface AlertBarProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof alertBarVariants> {
|
||||
dismissable?: boolean;
|
||||
onDismiss?: () => void;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const AlertBar = React.forwardRef<HTMLDivElement, AlertBarProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant,
|
||||
dismissable = true,
|
||||
onDismiss,
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isVisible, setIsVisible] = React.useState(true);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsVisible(false);
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const defaultIcon = React.useMemo(() => {
|
||||
switch (variant) {
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-4 w-4 flex-shrink-0" />;
|
||||
case "error":
|
||||
case "critical":
|
||||
return <AlertCircle className="h-4 w-4 flex-shrink-0" />;
|
||||
case "info":
|
||||
default:
|
||||
return <Info className="h-4 w-4 flex-shrink-0" />;
|
||||
}
|
||||
}, [variant]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(alertBarVariants({ variant }), className)}
|
||||
role="alert"
|
||||
{...props}
|
||||
>
|
||||
{icon ?? defaultIcon}
|
||||
<div className="flex-1">{children}</div>
|
||||
{dismissable && (
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AlertBar.displayName = "AlertBar";
|
||||
|
||||
export { AlertBar, alertBarVariants };
|
||||
48
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
36
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
57
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_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-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<any, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp: any = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
79
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
120
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border-2 bg-background p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
198
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
22
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
23
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
HTMLLabelElement,
|
||||
React.LabelHTMLAttributes<HTMLLabelElement> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = "Label"
|
||||
|
||||
export { Label }
|
||||
46
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden group", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-1.5 border-l border-l-transparent p-[1px] opacity-0 group-hover:opacity-100 transition-opacity duration-150",
|
||||
orientation === "horizontal" &&
|
||||
"h-1.5 flex-col border-t border-t-transparent p-[1px] opacity-0 group-hover:opacity-100 transition-opacity duration-150",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-muted/40 hover:bg-muted/70 transition-colors" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
118
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-primary/10 bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95',
|
||||
position === 'popper' && 'translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport className="p-1">
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
};
|
||||
29
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
27
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
117
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
53
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
22
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
28
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
322
src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { VRChatService } from "../services/vrchat";
|
||||
import { AccountService } from "../services/account";
|
||||
import { WebSocketService } from "../services/websocket";
|
||||
import { userStore, accountsStore } from "@/stores";
|
||||
import { setActiveAccountId } from "@/stores/account-scope";
|
||||
import type { User } from "../types/bindings";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
twoFactorMethods: string[];
|
||||
login: (
|
||||
email: string,
|
||||
password: string
|
||||
) => Promise<"success" | "needs_2fa" | "error">;
|
||||
verify2FA: (code: string, method: string) => Promise<boolean>;
|
||||
logout: () => Promise<void>;
|
||||
clearLocalSession: () => Promise<void>;
|
||||
setUser: (user: User | null) => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUserState] = useState<User | null>(userStore.getSnapshot());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [twoFactorMethods, setTwoFactorMethods] = useState<string[]>([]);
|
||||
|
||||
const checkAuth = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Eagerly load accounts cache first to avoid loading spinner in AccountSwitcher
|
||||
// Ensure cache is populated before UI renders
|
||||
await accountsStore.ensure().catch(() => undefined);
|
||||
|
||||
if (userStore.getSnapshot()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedUser = await AccountService.loadLastAccount();
|
||||
if (savedUser) {
|
||||
setActiveAccountId(savedUser.id);
|
||||
userStore.set(savedUser, { stale: true });
|
||||
|
||||
WebSocketService.start().catch((err) =>
|
||||
console.error("Failed to start WebSocket:", err)
|
||||
);
|
||||
|
||||
// Warm the cache in the background
|
||||
userStore.ensure({ force: true }).catch(() => undefined);
|
||||
userStore.ensureFriends({ force: true }).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSession = await VRChatService.checkSession();
|
||||
if (hasSession) {
|
||||
const currentUser = await userStore.refresh();
|
||||
setActiveAccountId(currentUser.id);
|
||||
|
||||
WebSocketService.start().catch((err) =>
|
||||
console.error("Failed to start WebSocket:", err)
|
||||
);
|
||||
|
||||
userStore.ensureFriends({ force: true }).catch(() => undefined);
|
||||
} else {
|
||||
userStore.clear();
|
||||
userStore.clearFriends();
|
||||
setActiveAccountId(null);
|
||||
}
|
||||
} catch {
|
||||
userStore.clear();
|
||||
userStore.clearFriends();
|
||||
setActiveAccountId(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = userStore.subscribe((value) => {
|
||||
setUserState(value);
|
||||
});
|
||||
checkAuth();
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
WebSocketService.stop().catch(() => undefined);
|
||||
};
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !(window as any).__TAURI_IPC__) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isDisposed = false;
|
||||
const unlistenFns: Array<() => void> = [];
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
|
||||
const addListener = async (event: string, handler: () => void) => {
|
||||
const unlisten = await listen(event, () => {
|
||||
if (!isDisposed) {
|
||||
handler();
|
||||
}
|
||||
});
|
||||
unlistenFns.push(unlisten);
|
||||
};
|
||||
|
||||
const refreshCurrentUser = () => {
|
||||
userStore.refresh().catch((error) => {
|
||||
console.error(`Failed to refresh user after ${"event"}`, error);
|
||||
});
|
||||
};
|
||||
|
||||
const refreshFriends = () => {
|
||||
userStore.refreshFriends().catch((error) => {
|
||||
console.error("Failed to refresh friends after event", error);
|
||||
});
|
||||
};
|
||||
|
||||
await addListener("user-update", refreshCurrentUser);
|
||||
await addListener("user-location", refreshCurrentUser);
|
||||
|
||||
// TODO: Strongly type these events
|
||||
const friendEvents = [
|
||||
"friend-added",
|
||||
"friend-update",
|
||||
"friend-online",
|
||||
"friend-active",
|
||||
"friend-offline",
|
||||
"friend-location",
|
||||
"friend-removed",
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
friendEvents.map((event) => addListener(event, refreshFriends))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to set up Tauri event listeners", error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
for (const unlisten of unlistenFns) {
|
||||
try {
|
||||
unlisten();
|
||||
} catch (error) {
|
||||
console.error("Failed to remove Tauri event listener", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = async (
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<"success" | "needs_2fa" | "error"> => {
|
||||
const response = await VRChatService.login(email, password);
|
||||
|
||||
if (response.type === "Success") {
|
||||
// First switch active account ID, then cache user data
|
||||
// This ensures userStore.set() emits to subscribers
|
||||
setActiveAccountId(response.user.id);
|
||||
userStore.set(response.user, { stale: true, scopeId: response.user.id });
|
||||
|
||||
let currentUser = response.user;
|
||||
try {
|
||||
currentUser = await userStore.refresh();
|
||||
await userStore.ensureFriends({ force: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh user after login", error);
|
||||
userStore.set(response.user, { scopeId: response.user.id });
|
||||
userStore.ensureFriends({ force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
setTwoFactorMethods([]);
|
||||
await accountsStore.saveFromUser(currentUser).catch((error) => {
|
||||
console.error("Failed to save account after login", error);
|
||||
});
|
||||
await accountsStore.refresh();
|
||||
WebSocketService.start().catch((err) =>
|
||||
console.error("Failed to start WebSocket:", err)
|
||||
);
|
||||
return "success";
|
||||
} else if (response.type === "TwoFactorRequired") {
|
||||
setTwoFactorMethods(response.methods);
|
||||
return "needs_2fa";
|
||||
}
|
||||
return "error";
|
||||
};
|
||||
|
||||
const verify2FA = async (code: string, method: string): Promise<boolean> => {
|
||||
const verified = await VRChatService.verify2FA(code, method);
|
||||
|
||||
if (verified) {
|
||||
let currentUser: User | null = null;
|
||||
try {
|
||||
currentUser = await userStore.refresh();
|
||||
await userStore.ensureFriends({ force: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh user after 2FA", error);
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
try {
|
||||
currentUser = await VRChatService.getCurrentUser();
|
||||
// Set active account ID before setting user in store to ensure emit happens
|
||||
setActiveAccountId(currentUser.id);
|
||||
userStore.set(currentUser, { scopeId: currentUser.id });
|
||||
userStore.ensureFriends({ force: true }).catch(() => undefined);
|
||||
} catch (fetchError) {
|
||||
console.error("Failed to fetch current user after 2FA", fetchError);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
setActiveAccountId(currentUser.id);
|
||||
userStore.ensureFriends({ force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
setTwoFactorMethods([]);
|
||||
await accountsStore.saveFromUser(currentUser).catch((error) => {
|
||||
console.error("Failed to save account after 2FA", error);
|
||||
});
|
||||
await accountsStore.refresh();
|
||||
WebSocketService.start().catch((err) =>
|
||||
console.error("Failed to start WebSocket:", err)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
const currentUserId = user?.id;
|
||||
try {
|
||||
await VRChatService.logout();
|
||||
} catch (error) {
|
||||
console.error("Logout failed", error);
|
||||
} finally {
|
||||
userStore.clear();
|
||||
userStore.clearFriends();
|
||||
setActiveAccountId(null);
|
||||
if (currentUserId) {
|
||||
await accountsStore.removeAccount(currentUserId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearLocalSession = async () => {
|
||||
try {
|
||||
await WebSocketService.stop();
|
||||
await VRChatService.clearSession();
|
||||
} catch {
|
||||
// Ignore errors when clearing session
|
||||
}
|
||||
userStore.clear();
|
||||
userStore.clearFriends();
|
||||
setActiveAccountId(null);
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
try {
|
||||
const refreshedUser = await userStore.refresh();
|
||||
setActiveAccountId(refreshedUser.id);
|
||||
} catch {
|
||||
userStore.clear();
|
||||
userStore.clearFriends();
|
||||
setActiveAccountId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const setUser = (value: User | null) => {
|
||||
if (value) {
|
||||
// Set active account ID before setting user in store to ensure emit happens
|
||||
setActiveAccountId(value.id);
|
||||
userStore.set(value, { scopeId: value.id });
|
||||
} else {
|
||||
userStore.clear();
|
||||
userStore.clearFriends();
|
||||
setActiveAccountId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
loading,
|
||||
twoFactorMethods,
|
||||
login,
|
||||
verify2FA,
|
||||
logout,
|
||||
clearLocalSession,
|
||||
setUser,
|
||||
refreshUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
29
src/devtools-animations.css
Normal file
@@ -0,0 +1,29 @@
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0.7;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.devtools-slide-up {
|
||||
animation: slide-up 0.4s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.devtools-slide-down {
|
||||
animation: slide-down 0.4s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
118
src/hooks/useCachedImage.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { convertFileSrc, isTauri } from "@tauri-apps/api/core";
|
||||
import { ImageCacheService } from "@/services/image-cache";
|
||||
import { logger, LogLevel } from "@/utils/logger";
|
||||
|
||||
// In-memory cache to prevent redundant cache checks across component re-renders
|
||||
const imageCache = new Map<string, string | null>();
|
||||
const pendingChecks = new Map<string, Promise<string | null>>();
|
||||
|
||||
export function useCachedImage(url?: string | null): string | null {
|
||||
const [cached, setCached] = useState<string | null>(() => {
|
||||
if (!url || url.trim().length === 0 || url.startsWith("data:")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return imageCache.get(url) ?? null;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!url || url.trim().length === 0 || url.startsWith("data:")) {
|
||||
setCached(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
if (imageCache.has(url)) {
|
||||
const cachedValue = imageCache.get(url);
|
||||
if (!cancelled && cachedValue !== undefined) {
|
||||
setCached(cachedValue);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const isInTauri = isTauri();
|
||||
|
||||
if (!isInTauri) {
|
||||
imageCache.set(url, url);
|
||||
setCached(url);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const existingCheck = pendingChecks.get(url);
|
||||
if (existingCheck) {
|
||||
existingCheck.then((result) => {
|
||||
if (!cancelled) {
|
||||
setCached(result);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
// Create new cache check promise
|
||||
const checkPromise = (async () => {
|
||||
try {
|
||||
const cachedPath = await ImageCacheService.checkCached(url);
|
||||
|
||||
if (cachedPath) {
|
||||
const convertedPath = convertFileSrc(cachedPath);
|
||||
imageCache.set(url, convertedPath);
|
||||
return convertedPath;
|
||||
} else {
|
||||
logger.log(
|
||||
"ImageCache",
|
||||
LogLevel.DEBUG,
|
||||
`Cache MISS: ${url}, using original URL`
|
||||
);
|
||||
imageCache.set(url, url);
|
||||
|
||||
try {
|
||||
const path = await ImageCacheService.cache(url);
|
||||
const convertedPath = convertFileSrc(path);
|
||||
|
||||
logger.debug("ImageCache", `Cached: ${url} -> ${convertedPath}`, {
|
||||
path,
|
||||
});
|
||||
imageCache.set(url, convertedPath);
|
||||
|
||||
if (!cancelled) {
|
||||
setCached(convertedPath);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("ImageCache", `Failed to cache: ${url}`, err);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("ImageCache", `Cache check failed: ${url}`, err);
|
||||
imageCache.set(url, url);
|
||||
return url;
|
||||
} finally {
|
||||
pendingChecks.delete(url);
|
||||
}
|
||||
})();
|
||||
|
||||
pendingChecks.set(url, checkPromise);
|
||||
|
||||
checkPromise.then((result) => {
|
||||
if (!cancelled) {
|
||||
setCached(result);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return cached;
|
||||
}
|
||||
41
src/hooks/useToast.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useToast() {
|
||||
return {
|
||||
success: (message: string, description?: string) => {
|
||||
toast.success(message, {
|
||||
description,
|
||||
});
|
||||
},
|
||||
error: (message: string, description?: string) => {
|
||||
toast.error(message, {
|
||||
description,
|
||||
});
|
||||
},
|
||||
info: (message: string, description?: string) => {
|
||||
toast.info(message, {
|
||||
description,
|
||||
});
|
||||
},
|
||||
warning: (message: string, description?: string) => {
|
||||
toast.warning(message, {
|
||||
description,
|
||||
});
|
||||
},
|
||||
loading: (message: string, description?: string) => {
|
||||
return toast.loading(message, {
|
||||
description,
|
||||
});
|
||||
},
|
||||
promise: <T,>(
|
||||
promise: Promise<T>,
|
||||
messages: {
|
||||
loading: string;
|
||||
success: string;
|
||||
error: string;
|
||||
}
|
||||
) => {
|
||||
return toast.promise(promise, messages);
|
||||
},
|
||||
};
|
||||
}
|
||||
90
src/hooks/useVRChatStatus.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { commands } from "@/types/bindings";
|
||||
import type { SystemStatus } from "@/types/bindings";
|
||||
|
||||
interface UseVRChatStatusOptions {
|
||||
/** Polling interval in milliseconds */
|
||||
pollInterval?: number;
|
||||
/** Whether to start polling immediately (default: true) */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseVRChatStatusReturn {
|
||||
status: SystemStatus | null;
|
||||
statusPageUrl: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: Date | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useVRChatStatus(
|
||||
options: UseVRChatStatusOptions = {}
|
||||
): UseVRChatStatusReturn {
|
||||
const { pollInterval = 60000, enabled = true } = options;
|
||||
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [statusPageUrl, setStatusPageUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log("[VRChatStatus] Fetching status...");
|
||||
const result = await commands.getVrchatStatus();
|
||||
|
||||
if (result.status === "ok") {
|
||||
const response = result.data;
|
||||
console.log("[VRChatStatus] Status received:", response.status);
|
||||
setStatus(response.status);
|
||||
setStatusPageUrl(response.page.url);
|
||||
setLastUpdated(new Date());
|
||||
} else {
|
||||
console.error("[VRChatStatus] Error response:", result.error);
|
||||
setError(result.error || "Failed to fetch VRChat status");
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Unknown error occurred";
|
||||
setError(errorMessage);
|
||||
console.error("[VRChatStatus] Exception:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchStatus();
|
||||
|
||||
if (pollInterval > 0) {
|
||||
intervalRef.current = window.setInterval(fetchStatus, pollInterval);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current !== null) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, pollInterval, fetchStatus]);
|
||||
|
||||
return {
|
||||
status,
|
||||
statusPageUrl,
|
||||
isLoading,
|
||||
error,
|
||||
lastUpdated,
|
||||
refresh: fetchStatus,
|
||||
};
|
||||
}
|
||||
37
src/i18n/config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import enTranslation from './locales/en.json';
|
||||
import jaTranslation from './locales/ja.json';
|
||||
import thTranslation from './locales/th.json';
|
||||
|
||||
const resources = {
|
||||
en: {
|
||||
translation: enTranslation,
|
||||
},
|
||||
ja: {
|
||||
translation: jaTranslation,
|
||||
},
|
||||
th: {
|
||||
translation: thTranslation,
|
||||
},
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
debug: false,
|
||||
// interpolation: {
|
||||
// escapeValue: false,
|
||||
// },
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
265
src/i18n/locales/en.json
Normal file
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"layout": {
|
||||
"sidebar": {
|
||||
"appName": "VRC Circle",
|
||||
"home": "Home",
|
||||
"worlds": "Worlds",
|
||||
"avatars": "Avatars",
|
||||
"logs": "Logs",
|
||||
"developerTools": "Developer Tools"
|
||||
},
|
||||
"friendsSidebar": {
|
||||
"title": "Friends",
|
||||
"collapse": "Collapse friends",
|
||||
"expand": "Expand friends",
|
||||
"location": {
|
||||
"offline": "Offline",
|
||||
"private": "Private instance",
|
||||
"traveling": "Traveling",
|
||||
"group": "Group instance",
|
||||
"instance": "In world instance"
|
||||
},
|
||||
"status": {
|
||||
"website": "On website"
|
||||
},
|
||||
"sections": {
|
||||
"inWorld": {
|
||||
"title": "In World",
|
||||
"empty": "No friends in a world right now"
|
||||
},
|
||||
"active": {
|
||||
"title": "Active",
|
||||
"empty": "No friends active on VRChat"
|
||||
},
|
||||
"offline": {
|
||||
"title": "Offline",
|
||||
"empty": "No friends offline"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"collapsed": "No friends to display"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Failed to load friends"
|
||||
},
|
||||
"onlineCount": "{{count}} online"
|
||||
},
|
||||
"developerTools": {
|
||||
"tabs": {
|
||||
"logger": "Logger",
|
||||
"databaseStudio": "Database Studio",
|
||||
"storeStudio": "Store Studio",
|
||||
"imageCache": "Image Cache"
|
||||
}
|
||||
}
|
||||
},
|
||||
"component": {
|
||||
"developerTools": {
|
||||
"logger": {
|
||||
"title": "Logger",
|
||||
"sidebar": {
|
||||
"refresh": "Refresh",
|
||||
"autoRefresh": "Auto-refresh",
|
||||
"export": "Export",
|
||||
"clearAll": "Clear All",
|
||||
"searchLabel": "Search",
|
||||
"searchPlaceholder": "Search...",
|
||||
"source": "Source",
|
||||
"level": "Level",
|
||||
"counts": "{{filtered}} / {{total}} logs"
|
||||
},
|
||||
"main": {
|
||||
"title": "Log Entries",
|
||||
"description": "View and filter application logs from frontend and backend",
|
||||
"empty": "No logs found matching the current filters"
|
||||
},
|
||||
"toasts": {
|
||||
"refreshed": "Logs refreshed",
|
||||
"refreshFailed": "Failed to refresh logs",
|
||||
"cleared": "All logs cleared",
|
||||
"clearFailed": "Failed to clear logs",
|
||||
"exported": "Logs exported successfully",
|
||||
"exportFailed": "Failed to export logs"
|
||||
},
|
||||
"confirm": {
|
||||
"clearAll": "Are you sure you want to clear all logs?"
|
||||
}
|
||||
},
|
||||
"databaseStudio": {
|
||||
"sidebar": {
|
||||
"title": "Database Studio",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"tabs": {
|
||||
"browse": "Browse Data",
|
||||
"query": "Custom Query"
|
||||
},
|
||||
"browse": {
|
||||
"noTableSelected": {
|
||||
"title": "No Table Selected",
|
||||
"description": "Select a table from the sidebar to view its data"
|
||||
},
|
||||
"schema": {
|
||||
"title": "Schema",
|
||||
"show": "Show",
|
||||
"hide": "Hide",
|
||||
"hidden": "{{count}} column(s) hidden"
|
||||
},
|
||||
"data": {
|
||||
"title": "Data",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageInfo": "Page {{page}} of {{total}}",
|
||||
"loading": "Loading...",
|
||||
"noData": "No data available",
|
||||
"table": {
|
||||
"noRows": "No rows in this table",
|
||||
"actions": "Actions",
|
||||
"saveTitle": "Save changes",
|
||||
"deleteTitle": "Delete row"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
"loadTablesFailed": "Failed to load database tables",
|
||||
"loadTableDataFailed": "Failed to load table data",
|
||||
"enterQuery": "Please enter a query",
|
||||
"queryExecuted": "Query executed successfully",
|
||||
"queryFailed": "Query failed: {{message}}",
|
||||
"cannotUpdateNoPK": "Cannot update: No primary key found",
|
||||
"rowUpdated": "Row updated successfully",
|
||||
"updateFailed": "Failed to update row: {{message}}",
|
||||
"cannotDeleteNoPK": "Cannot delete: No primary key found",
|
||||
"rowDeleted": "Row deleted successfully",
|
||||
"deleteFailed": "Failed to delete row: {{message}}"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteRow": "Delete row where {{column}} = {{value}}?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loginRequired": {
|
||||
"title": "Login Required",
|
||||
"bottomText": "Sign in to your VRChat account to access this feature.",
|
||||
"signIn": "Sign In"
|
||||
},
|
||||
"accountMenu": {
|
||||
"logout": "Logout",
|
||||
"setStatus": "Set Status",
|
||||
"customStatusPlaceholder": "Custom status message...",
|
||||
"setCustomStatus": "Set Custom Status",
|
||||
"clearStatus": "Clear status",
|
||||
"statusCopied": "Status copied to clipboard!",
|
||||
"statusCopyFailed": "Failed to copy status",
|
||||
"viewProfile": "View Profile",
|
||||
"switchAccount": "Switch Account",
|
||||
"addAccount": "Add Account",
|
||||
"settings": "Settings",
|
||||
"statusUpdateFailed": "Failed to update status. Please try again.",
|
||||
"accountSwitchFailed": "Failed to switch account. Please try again.",
|
||||
"unknownUser": "Unknown User",
|
||||
"unknownUsername": "Unknown"
|
||||
},
|
||||
"themeSwitcher": {
|
||||
"toggle": "Toggle theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"subtitle": "Sign in to your VRChat account",
|
||||
"welcomeTitle": "Welcome back",
|
||||
"welcomeDescription": "Enter your credentials to continue",
|
||||
"placeholderEmail": "vrcat@example.com",
|
||||
"placeholderPassword": "••••••••",
|
||||
"signingIn": "Signing in...",
|
||||
"signInButton": "Sign In",
|
||||
"skip": "Skip logging in"
|
||||
},
|
||||
"worlds": {
|
||||
"title": "Worlds",
|
||||
"description": "Browse and manage your uploaded VRChat worlds",
|
||||
"showingSingle": "Showing {{count}} world",
|
||||
"showingMultiple": "Showing {{count}} worlds",
|
||||
"refresh": {
|
||||
"loading": "Loading...",
|
||||
"label": "Refresh"
|
||||
},
|
||||
"error": {
|
||||
"title": "Unable to load worlds",
|
||||
"tryAgain": "Try again"
|
||||
},
|
||||
"noItemsTitle": "No worlds yet",
|
||||
"noItemsDescription": "We couldn't find any uploaded worlds for this account. Upload a world to VRChat to see it here.",
|
||||
"noPreview": "No preview available",
|
||||
"byAuthor": "By {{author}}",
|
||||
"authorUnknown": "Author unknown",
|
||||
"labels": {
|
||||
"visits": "Visits",
|
||||
"favorites": "Favorites",
|
||||
"capacity": "Capacity",
|
||||
"updatedPrefix": "Updated {{date}}"
|
||||
}
|
||||
},
|
||||
"avatars": {
|
||||
"title": "Avatars",
|
||||
"description": "Browse and manage your uploaded VRChat avatars",
|
||||
"showingSingle": "Showing {{count}} avatar",
|
||||
"showingMultiple": "Showing {{count}} avatars",
|
||||
"refresh": {
|
||||
"loading": "Loading...",
|
||||
"label": "Refresh"
|
||||
},
|
||||
"error": {
|
||||
"title": "Unable to load avatars",
|
||||
"tryAgain": "Try again"
|
||||
},
|
||||
"noItemsTitle": "No avatars yet",
|
||||
"noItemsDescription": "We couldn't find any uploaded avatars for this account. Upload an avatar to VRChat to see it here.",
|
||||
"noPreview": "No preview available",
|
||||
"byAuthor": "By {{author}}",
|
||||
"authorUnknown": "Author unknown",
|
||||
"labels": {
|
||||
"performance": "Performance",
|
||||
"release": "Release",
|
||||
"updated": "Updated",
|
||||
"version": "Version",
|
||||
"style": "Style",
|
||||
"createdPrefix": "Created {{date}}",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"status": {
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"hidden": "Hidden",
|
||||
"all": "All"
|
||||
}
|
||||
},
|
||||
"verify2fa": {
|
||||
"title": "Two-Factor Authentication",
|
||||
"authenticator": "Authenticator App",
|
||||
"email": "Email Code",
|
||||
"enterCode": "Enter verification code",
|
||||
"appInstruction": "Open your authenticator app and enter the 6-digit code.",
|
||||
"emailInstruction": "Check your email for the one-time code and enter it below.",
|
||||
"code": "Verification Code",
|
||||
"placeholder": "000000",
|
||||
"verifying": "Verifying...",
|
||||
"verify": "Verify",
|
||||
"back": "Back to Sign In",
|
||||
"help": "Can't sign in? Contact support or try another sign-in method.",
|
||||
"invalid": "Invalid code. Please try again."
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"joinMe": "Join Me",
|
||||
"askMe": "Ask Me",
|
||||
"busy": "Busy"
|
||||
}
|
||||
}
|
||||
}
|
||||
265
src/i18n/locales/ja.json
Normal file
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"layout": {
|
||||
"sidebar": {
|
||||
"appName": "VRC Circle",
|
||||
"home": "ホーム",
|
||||
"worlds": "ワールド",
|
||||
"avatars": "アバター",
|
||||
"logs": "ログ",
|
||||
"developerTools": "開発者ツール"
|
||||
},
|
||||
"friendsSidebar": {
|
||||
"title": "フレンド",
|
||||
"collapse": "フレンドを折りたたむ",
|
||||
"expand": "フレンドを展開",
|
||||
"location": {
|
||||
"offline": "オフライン",
|
||||
"private": "プライベートインスタンス",
|
||||
"traveling": "移動中",
|
||||
"group": "グループインスタンス",
|
||||
"instance": "ワールドのインスタンス"
|
||||
},
|
||||
"status": {
|
||||
"website": "ウェブサイト上"
|
||||
},
|
||||
"sections": {
|
||||
"inWorld": {
|
||||
"title": "ワールド内",
|
||||
"empty": "現在ワールドにいるフレンドはいません"
|
||||
},
|
||||
"active": {
|
||||
"title": "アクティブ",
|
||||
"empty": "VRChatでアクティブなフレンドはいません"
|
||||
},
|
||||
"offline": {
|
||||
"title": "オフライン",
|
||||
"empty": "オフラインのフレンドはいません"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"collapsed": "表示するフレンドがいません"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "フレンドの読み込みに失敗しました"
|
||||
},
|
||||
"onlineCount": "{{count}}人オンライン"
|
||||
},
|
||||
"developerTools": {
|
||||
"tabs": {
|
||||
"logger": "ロガー",
|
||||
"databaseStudio": "データベース",
|
||||
"storeStudio": "ストアスタジオ",
|
||||
"imageCache": "画像キャッシュ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"component": {
|
||||
"developerTools": {
|
||||
"logger": {
|
||||
"title": "ロガー",
|
||||
"sidebar": {
|
||||
"refresh": "更新",
|
||||
"autoRefresh": "自動更新",
|
||||
"export": "エクスポート",
|
||||
"clearAll": "全てクリア",
|
||||
"searchLabel": "検索",
|
||||
"searchPlaceholder": "検索...",
|
||||
"source": "ソース",
|
||||
"level": "レベル",
|
||||
"counts": "{{filtered}} / {{total}} 件のログ"
|
||||
},
|
||||
"main": {
|
||||
"title": "ログエントリ",
|
||||
"description": "フロントエンドとバックエンドのアプリケーションログを表示・フィルタリングします",
|
||||
"empty": "現在のフィルタに一致するログはありません"
|
||||
},
|
||||
"toasts": {
|
||||
"refreshed": "ログを更新しました",
|
||||
"refreshFailed": "ログの更新に失敗しました",
|
||||
"cleared": "全てのログをクリアしました",
|
||||
"clearFailed": "ログのクリアに失敗しました",
|
||||
"exported": "ログをエクスポートしました",
|
||||
"exportFailed": "ログのエクスポートに失敗しました"
|
||||
},
|
||||
"confirm": {
|
||||
"clearAll": "全てのログをクリアしてもよろしいですか?"
|
||||
}
|
||||
},
|
||||
"databaseStudio": {
|
||||
"sidebar": {
|
||||
"title": "データベース",
|
||||
"refresh": "更新"
|
||||
},
|
||||
"tabs": {
|
||||
"browse": "データ参照",
|
||||
"query": "カスタムクエリ"
|
||||
},
|
||||
"browse": {
|
||||
"noTableSelected": {
|
||||
"title": "テーブルが選択されていません",
|
||||
"description": "サイドバーからテーブルを選択してデータを表示してください"
|
||||
},
|
||||
"schema": {
|
||||
"title": "スキーマ",
|
||||
"show": "表示",
|
||||
"hide": "非表示",
|
||||
"hidden": "{{count}} 列が非表示"
|
||||
},
|
||||
"data": {
|
||||
"title": "データ",
|
||||
"previous": "前へ",
|
||||
"next": "次へ",
|
||||
"pageInfo": "ページ {{page}} / {{total}}",
|
||||
"loading": "読み込み中...",
|
||||
"noData": "データがありません",
|
||||
"table": {
|
||||
"noRows": "このテーブルに行がありません",
|
||||
"actions": "操作",
|
||||
"saveTitle": "変更を保存",
|
||||
"deleteTitle": "行を削除"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
"loadTablesFailed": "データベースのテーブルの読み込みに失敗しました",
|
||||
"loadTableDataFailed": "テーブルデータの読み込みに失敗しました",
|
||||
"enterQuery": "クエリを入力してください",
|
||||
"queryExecuted": "クエリが正常に実行されました",
|
||||
"queryFailed": "クエリの実行に失敗しました: {{message}}",
|
||||
"cannotUpdateNoPK": "更新できません: 主キーが見つかりません",
|
||||
"rowUpdated": "行を更新しました",
|
||||
"updateFailed": "行の更新に失敗しました: {{message}}",
|
||||
"cannotDeleteNoPK": "削除できません: 主キーが見つかりません",
|
||||
"rowDeleted": "行を削除しました",
|
||||
"deleteFailed": "行の削除に失敗しました: {{message}}"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteRow": "{{column}} = {{value}} の行を削除しますか?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loginRequired": {
|
||||
"title": "ログインが必要です",
|
||||
"bottomText": "この機能にアクセスするには VRChat アカウントでサインインしてください。",
|
||||
"signIn": "サインイン"
|
||||
},
|
||||
"accountMenu": {
|
||||
"logout": "ログアウト",
|
||||
"setStatus": "ステータスを設定",
|
||||
"customStatusPlaceholder": "カスタムステータスメッセージ...",
|
||||
"setCustomStatus": "カスタムステータスを設定",
|
||||
"clearStatus": "ステータスをクリア",
|
||||
"statusCopied": "ステータスをクリップボードにコピーしました!",
|
||||
"statusCopyFailed": "ステータスのコピーに失敗しました",
|
||||
"viewProfile": "プロフィールを見る",
|
||||
"switchAccount": "アカウントを切り替え",
|
||||
"addAccount": "アカウントを追加",
|
||||
"settings": "設定",
|
||||
"statusUpdateFailed": "ステータスの更新に失敗しました。もう一度お試しください。",
|
||||
"accountSwitchFailed": "アカウントの切り替えに失敗しました。もう一度お試しください。",
|
||||
"unknownUser": "不明なユーザー",
|
||||
"unknownUsername": "不明"
|
||||
},
|
||||
"themeSwitcher": {
|
||||
"toggle": "テーマを切り替え",
|
||||
"light": "ライト",
|
||||
"dark": "ダーク",
|
||||
"system": "システム"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"login": {
|
||||
"title": "サインイン",
|
||||
"subtitle": "VRChat アカウントでサインイン",
|
||||
"welcomeTitle": "おかえりなさい",
|
||||
"welcomeDescription": "続行するには資格情報を入力してください",
|
||||
"placeholderEmail": "vrchat@example.com",
|
||||
"placeholderPassword": "••••••••",
|
||||
"signingIn": "サインイン中...",
|
||||
"signInButton": "サインイン",
|
||||
"skip": "ログインをスキップ"
|
||||
},
|
||||
"worlds": {
|
||||
"title": "ワールド",
|
||||
"description": "アップロードした VRChat ワールドを閲覧・管理します",
|
||||
"showingSingle": "{{count}} 件のワールドを表示",
|
||||
"showingMultiple": "{{count}} 件のワールドを表示",
|
||||
"refresh": {
|
||||
"loading": "読み込み中...",
|
||||
"label": "更新"
|
||||
},
|
||||
"error": {
|
||||
"title": "ワールドの読み込みに失敗しました",
|
||||
"tryAgain": "再試行"
|
||||
},
|
||||
"noItemsTitle": "まだワールドがありません",
|
||||
"noItemsDescription": "このアカウントにアップロードされたワールドが見つかりませんでした。ワールドを VRChat にアップロードするとここに表示されます。",
|
||||
"noPreview": "プレビューはありません",
|
||||
"byAuthor": "作成者: {{author}}",
|
||||
"authorUnknown": "作成者不明",
|
||||
"labels": {
|
||||
"visits": "訪問数",
|
||||
"favorites": "お気に入り",
|
||||
"capacity": "容量",
|
||||
"updatedPrefix": "更新日 {{date}}"
|
||||
}
|
||||
},
|
||||
"avatars": {
|
||||
"title": "アバター",
|
||||
"description": "アップロードした VRChat アバターを閲覧・管理します",
|
||||
"showingSingle": "{{count}} 件のアバターを表示",
|
||||
"showingMultiple": "{{count}} 件のアバターを表示",
|
||||
"refresh": {
|
||||
"loading": "読み込み中...",
|
||||
"label": "更新"
|
||||
},
|
||||
"error": {
|
||||
"title": "アバターの読み込みに失敗しました",
|
||||
"tryAgain": "再試行"
|
||||
},
|
||||
"noItemsTitle": "まだアバターがありません",
|
||||
"noItemsDescription": "このアカウントにアップロードされたアバターが見つかりませんでした。アバターを VRChat にアップロードするとここに表示されます。",
|
||||
"noPreview": "プレビューはありません",
|
||||
"byAuthor": "作成者: {{author}}",
|
||||
"authorUnknown": "作成者不明",
|
||||
"labels": {
|
||||
"performance": "パフォーマンス",
|
||||
"release": "リリース",
|
||||
"updated": "更新日",
|
||||
"version": "バージョン",
|
||||
"style": "スタイル",
|
||||
"createdPrefix": "作成日 {{date}}",
|
||||
"unknown": "不明"
|
||||
},
|
||||
"status": {
|
||||
"public": "公開",
|
||||
"private": "プライベート",
|
||||
"hidden": "非表示",
|
||||
"all": "すべて"
|
||||
}
|
||||
},
|
||||
"verify2fa": {
|
||||
"title": "二段階認証",
|
||||
"authenticator": "認証アプリ",
|
||||
"email": "メールコード",
|
||||
"enterCode": "確認コードを入力",
|
||||
"appInstruction": "認証アプリを開き、6 桁のコードを入力してください。",
|
||||
"emailInstruction": "メールに送信されたワンタイムコードを確認し、下に入力してください。",
|
||||
"code": "確認コード",
|
||||
"placeholder": "000000",
|
||||
"verifying": "確認中...",
|
||||
"verify": "確認",
|
||||
"back": "サインインに戻る",
|
||||
"help": "サインインできませんか?サポートに連絡するか別のサインイン方法を試してください。",
|
||||
"invalid": "無効なコードです。もう一度お試しください。"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"status": {
|
||||
"active": "アクティブ",
|
||||
"joinMe": "参加して",
|
||||
"askMe": "聞いて",
|
||||
"busy": "取り込み中"
|
||||
}
|
||||
}
|
||||
}
|
||||
265
src/i18n/locales/th.json
Normal file
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"layout": {
|
||||
"sidebar": {
|
||||
"appName": "VRC Circle",
|
||||
"home": "หน้าแรก",
|
||||
"worlds": "เวิลด์",
|
||||
"avatars": "อวาตาร์",
|
||||
"logs": "บันทึก",
|
||||
"developerTools": "เครื่องมือสำหรับนักพัฒนา"
|
||||
},
|
||||
"friendsSidebar": {
|
||||
"title": "เพื่อน",
|
||||
"collapse": "ยุบรายการเพื่อน",
|
||||
"expand": "ขยายรายการเพื่อน",
|
||||
"location": {
|
||||
"offline": "ออฟไลน์",
|
||||
"private": "อินสแตนซ์ส่วนตัว",
|
||||
"traveling": "กำลังเดินทาง",
|
||||
"group": "อินสแตนซ์กลุ่ม",
|
||||
"instance": "อยู่ในอินสแตนซ์ของเวิลด์"
|
||||
},
|
||||
"status": {
|
||||
"website": "อยู่บนเว็บไซต์"
|
||||
},
|
||||
"sections": {
|
||||
"inWorld": {
|
||||
"title": "ในเวิลด์",
|
||||
"empty": "ขณะนี้ไม่มีเพื่อนอยู่ในเวิลด์"
|
||||
},
|
||||
"active": {
|
||||
"title": "กำลังใช้งาน",
|
||||
"empty": "ไม่มีเพื่อนที่กำลังใช้งาน VRChat"
|
||||
},
|
||||
"offline": {
|
||||
"title": "ออฟไลน์",
|
||||
"empty": "ไม่มีเพื่อนที่ออฟไลน์"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"collapsed": "ไม่มีเพื่อนให้แสดง"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "โหลดเพื่อนไม่สำเร็จ"
|
||||
},
|
||||
"onlineCount": "{{count}} คนออนไลน์"
|
||||
},
|
||||
"developerTools": {
|
||||
"tabs": {
|
||||
"logger": "บันทึก",
|
||||
"databaseStudio": "ฐานข้อมูล",
|
||||
"storeStudio": "สตูดิโอสโตร์",
|
||||
"imageCache": "แคชรูปภาพ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"component": {
|
||||
"developerTools": {
|
||||
"logger": {
|
||||
"title": "บันทึก",
|
||||
"sidebar": {
|
||||
"refresh": "รีเฟรช",
|
||||
"autoRefresh": "รีเฟรชอัตโนมัติ",
|
||||
"export": "ส่งออก",
|
||||
"clearAll": "ล้างทั้งหมด",
|
||||
"searchLabel": "ค้นหา",
|
||||
"searchPlaceholder": "ค้นหา...",
|
||||
"source": "แหล่งที่มา",
|
||||
"level": "ระดับ",
|
||||
"counts": "{{filtered}} / {{total}} บันทึก"
|
||||
},
|
||||
"main": {
|
||||
"title": "รายการบันทึก",
|
||||
"description": "ดูและกรองบันทึกแอปพลิเคชันจากฝั่ง frontend และ backend",
|
||||
"empty": "ไม่พบบันทึกที่ตรงกับตัวกรองปัจจุบัน"
|
||||
},
|
||||
"toasts": {
|
||||
"refreshed": "รีเฟรชบันทึกแล้ว",
|
||||
"refreshFailed": "รีเฟรชบันทึกไม่สำเร็จ",
|
||||
"cleared": "ล้างบันทึกทั้งหมดแล้ว",
|
||||
"clearFailed": "ล้างบันทึกไม่สำเร็จ",
|
||||
"exported": "ส่งออกบันทึกเรียบร้อยแล้ว",
|
||||
"exportFailed": "ส่งออกบันทึกไม่สำเร็จ"
|
||||
},
|
||||
"confirm": {
|
||||
"clearAll": "คุณแน่ใจหรือไม่ว่าต้องการล้างบันทึกทั้งหมด?"
|
||||
}
|
||||
},
|
||||
"databaseStudio": {
|
||||
"sidebar": {
|
||||
"title": "สตูดิโอฐานข้อมูล",
|
||||
"refresh": "รีเฟรช"
|
||||
},
|
||||
"tabs": {
|
||||
"browse": "เรียกดูข้อมูล",
|
||||
"query": "คิวรีที่กำหนดเอง"
|
||||
},
|
||||
"browse": {
|
||||
"noTableSelected": {
|
||||
"title": "ยังไม่ได้เลือกตาราง",
|
||||
"description": "เลือกตารางจากแถบด้านข้างเพื่อดูข้อมูล"
|
||||
},
|
||||
"schema": {
|
||||
"title": "สกีมา",
|
||||
"show": "แสดง",
|
||||
"hide": "ซ่อน",
|
||||
"hidden": "มีคอลัมน์ที่ซ่อนอยู่ {{count}} คอลัมน์"
|
||||
},
|
||||
"data": {
|
||||
"title": "ข้อมูล",
|
||||
"previous": "ก่อนหน้า",
|
||||
"next": "ถัดไป",
|
||||
"pageInfo": "หน้า {{page}} จาก {{total}}",
|
||||
"loading": "กำลังโหลด...",
|
||||
"noData": "ไม่มีข้อมูล",
|
||||
"table": {
|
||||
"noRows": "ไม่มีแถวในตารางนี้",
|
||||
"actions": "การกระทำ",
|
||||
"saveTitle": "บันทึกการเปลี่ยนแปลง",
|
||||
"deleteTitle": "ลบแถว"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
"loadTablesFailed": "โหลดตารางฐานข้อมูลไม่สำเร็จ",
|
||||
"loadTableDataFailed": "โหลดข้อมูลตารางไม่สำเร็จ",
|
||||
"enterQuery": "กรุณาป้อนคำสั่งคิวรี",
|
||||
"queryExecuted": "คิวรีดำเนินการสำเร็จ",
|
||||
"queryFailed": "คิวรีล้มเหลว: {{message}}",
|
||||
"cannotUpdateNoPK": "ไม่สามารถอัปเดต: ไม่พบ primary key",
|
||||
"rowUpdated": "อัปเดตแถวสำเร็จ",
|
||||
"updateFailed": "อัปเดตแถวไม่สำเร็จ: {{message}}",
|
||||
"cannotDeleteNoPK": "ไม่สามารถลบ: ไม่พบ primary key",
|
||||
"rowDeleted": "ลบแถวสำเร็จ",
|
||||
"deleteFailed": "ลบแถวไม่สำเร็จ: {{message}}"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteRow": "ต้องการลบแถวที่ {{column}} = {{value }} หรือไม่"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loginRequired": {
|
||||
"title": "ต้องเข้าสู่ระบบ",
|
||||
"bottomText": "ลงชื่อเข้าใช้บัญชี VRChat ของคุณเพื่อเข้าถึงฟีเจอร์นี้",
|
||||
"signIn": "ลงชื่อเข้าใช้"
|
||||
},
|
||||
"accountMenu": {
|
||||
"logout": "ออกจากระบบ",
|
||||
"setStatus": "ตั้งค่าสถานะ",
|
||||
"customStatusPlaceholder": "ข้อความสถานะแบบกำหนดเอง...",
|
||||
"setCustomStatus": "ตั้งค่าสถานะแบบกำหนดเอง",
|
||||
"clearStatus": "ล้างสถานะ",
|
||||
"statusCopied": "คัดลอกสถานะไปยังคลิปบอร์ดแล้ว!",
|
||||
"statusCopyFailed": "คัดลอกสถานะไม่สำเร็จ",
|
||||
"viewProfile": "ดูโปรไฟล์",
|
||||
"switchAccount": "สลับบัญชี",
|
||||
"addAccount": "เพิ่มบัญชี",
|
||||
"settings": "การตั้งค่า",
|
||||
"statusUpdateFailed": "อัปเดตสถานะไม่สำเร็จ กรุณาลองอีกครั้ง",
|
||||
"accountSwitchFailed": "สลับบัญชีไม่สำเร็จ กรุณาลองอีกครั้ง",
|
||||
"unknownUser": "ผู้ใช้ไม่ทราบ",
|
||||
"unknownUsername": "ชื่อผู้ใช้ไม่ทราบ"
|
||||
},
|
||||
"themeSwitcher": {
|
||||
"toggle": "สลับธีม",
|
||||
"light": "สว่าง",
|
||||
"dark": "มืด",
|
||||
"system": "ระบบ"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"login": {
|
||||
"title": "ลงชื่อเข้าใช้",
|
||||
"subtitle": "ลงชื่อเข้าใช้ด้วยบัญชี VRChat ของคุณ",
|
||||
"welcomeTitle": "ยินดีต้อนรับกลับ",
|
||||
"welcomeDescription": "กรอกข้อมูลประจำตัวของคุณเพื่อดำเนินการต่อ",
|
||||
"placeholderEmail": "you@example.com",
|
||||
"placeholderPassword": "••••••••",
|
||||
"signingIn": "กำลังลงชื่อเข้าใช้...",
|
||||
"signInButton": "ลงชื่อเข้าใช้",
|
||||
"skip": "ข้ามการลงชื่อเข้าใช้"
|
||||
},
|
||||
"worlds": {
|
||||
"title": "เวิลด์",
|
||||
"description": "เรียกดูและจัดการเวิลด์ VRChat ที่คุณอัปโหลด",
|
||||
"showingSingle": "แสดง {{count}} เวิลด์",
|
||||
"showingMultiple": "แสดง {{count}} เวิลด์",
|
||||
"refresh": {
|
||||
"loading": "กำลังโหลด...",
|
||||
"label": "รีเฟรช"
|
||||
},
|
||||
"error": {
|
||||
"title": "ไม่สามารถโหลดเวิลด์ได้",
|
||||
"tryAgain": "ลองอีกครั้ง"
|
||||
},
|
||||
"noItemsTitle": "ยังไม่มีเวิลด์",
|
||||
"noItemsDescription": "ไม่พบเวิลด์ที่อัปโหลดสำหรับบัญชีนี้ อัปโหลดเวิลด์ไปยัง VRChat เพื่อให้ปรากฏที่นี่",
|
||||
"noPreview": "ไม่มีตัวอย่าง",
|
||||
"byAuthor": "โดย {{author}}",
|
||||
"authorUnknown": "ผู้สร้างไม่ทราบ",
|
||||
"labels": {
|
||||
"visits": "ผู้เข้าชม",
|
||||
"favorites": "บุ๊คมาร์ก",
|
||||
"capacity": "ความจุ",
|
||||
"updatedPrefix": "อัปเดต {{date}}"
|
||||
}
|
||||
},
|
||||
"avatars": {
|
||||
"title": "อวาตาร์",
|
||||
"description": "เรียกดูและจัดการอวาตาร์ VRChat ที่คุณอัปโหลด",
|
||||
"showingSingle": "แสดง {{count}} อวาตาร์",
|
||||
"showingMultiple": "แสดง {{count}} อวาตาร์",
|
||||
"refresh": {
|
||||
"loading": "กำลังโหลด...",
|
||||
"label": "รีเฟรช"
|
||||
},
|
||||
"error": {
|
||||
"title": "ไม่สามารถโหลดอวาตาร์ได้",
|
||||
"tryAgain": "ลองอีกครั้ง"
|
||||
},
|
||||
"noItemsTitle": "ยังไม่มีอวาตาร์",
|
||||
"noItemsDescription": "ไม่พบอวาตาร์ที่อัปโหลดสำหรับบัญชีนี้ อัปโหลดอวาตาร์ไปยัง VRChat เพื่อให้ปรากฏที่นี่",
|
||||
"noPreview": "ไม่มีตัวอย่าง",
|
||||
"byAuthor": "โดย {{author}}",
|
||||
"authorUnknown": "ผู้สร้างไม่ทราบ",
|
||||
"labels": {
|
||||
"performance": "ประสิทธิภาพ",
|
||||
"release": "การปล่อย",
|
||||
"updated": "อัปเดต",
|
||||
"version": "เวอร์ชัน",
|
||||
"style": "สไตล์",
|
||||
"createdPrefix": "สร้างเมื่อ {{date}}",
|
||||
"unknown": "ไม่ทราบ"
|
||||
},
|
||||
"status": {
|
||||
"public": "สาธารณะ",
|
||||
"private": "ส่วนตัว",
|
||||
"hidden": "ซ่อน",
|
||||
"all": "ทั้งหมด"
|
||||
}
|
||||
},
|
||||
"verify2fa": {
|
||||
"title": "การยืนยันแบบสองปัจจัย",
|
||||
"authenticator": "แอปตัวตรวจสอบสิทธิ์",
|
||||
"email": "รหัสทางอีเมล",
|
||||
"enterCode": "ป้อนรหัสยืนยัน",
|
||||
"appInstruction": "เปิดแอปตัวตรวจสอบสิทธิ์ของคุณแล้วป้อนรหัส 6 หลัก",
|
||||
"emailInstruction": "ตรวจสอบอีเมลของคุณสำหรับรหัสแบบครั้งเดียวแล้วป้อนด้านล่าง",
|
||||
"code": "รหัสยืนยัน",
|
||||
"placeholder": "000000",
|
||||
"verifying": "กำลังยืนยัน...",
|
||||
"verify": "ยืนยัน",
|
||||
"back": "กลับไปที่การเข้าสู่ระบบ",
|
||||
"help": "ไม่สามารถลงชื่อเข้าใช้ได้หรือไม่? ติดต่อฝ่ายสนับสนุนหรือลองวิธีการลงชื่อเข้าใช้อื่น",
|
||||
"invalid": "รหัสไม่ถูกต้อง กรุณาลองอีกครั้ง"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"status": {
|
||||
"active": "กำลังใช้งาน",
|
||||
"joinMe": "เข้าร่วมฉัน",
|
||||
"askMe": "ถามฉัน",
|
||||
"busy": "ไม่ว่าง"
|
||||
}
|
||||
}
|
||||
}
|
||||