Compare commits
42 Commits
2281993edb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86a04cf5cb | ||
|
|
0a2b859e7a | ||
|
|
7a6288cc02 | ||
|
|
0f8b96e73c | ||
|
|
9a2a9343d2 | ||
|
|
4352e4c792 | ||
|
|
b303d2f5cf | ||
|
|
9aa628a9d2 | ||
|
|
cbd4b93fd4 | ||
|
|
7d94e29dcb | ||
|
|
adf16210a4 | ||
|
|
adcf9832f4 | ||
|
|
53a39126f4 | ||
|
|
f43fec3362 | ||
|
|
d17dc5558c | ||
|
|
79d9bd7f76 | ||
|
|
756e1307f1 | ||
|
|
a25fb9b5eb | ||
|
|
9db9915e56 | ||
|
|
9366dbca14 | ||
|
|
9784d259ba | ||
|
|
b77dff03c1 | ||
|
|
8945eb6ad6 | ||
|
|
dc1124dff6 | ||
|
|
d551223566 | ||
|
|
1d2c27abbd | ||
|
|
41659c9299 | ||
|
|
7faf2d38ab | ||
|
|
7ac979a4f4 | ||
|
|
92846c6d3a | ||
|
|
75b52caf85 | ||
|
|
fde19f47c8 | ||
|
|
3516f7f205 | ||
|
|
d48291f6ed | ||
|
|
620418c405 | ||
|
|
6f477a1aa7 | ||
|
|
da4566c789 | ||
|
|
7c3a8aab64 | ||
|
|
72914da637 | ||
|
|
70131ecd2d | ||
|
|
f559a6aa73 | ||
|
|
b024b19ecc |
360
bun.lock
360
bun.lock
@@ -9,55 +9,55 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@number-flow/react": "^0.5.10",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-context-menu": "^2.2.15",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@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-slider": "^1.3.5",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.539.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-day-picker": "^9.8.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router": "^7.7.1",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^9.11.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router": "^7.9.3",
|
||||
"recharts": "2.15.4",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tailwindcss": "^4.1.14",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"@types/node": "^24.6.2",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-plugin-react-hooks": "^6.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.23",
|
||||
"globals": "^16.4.0",
|
||||
"prettier": "^3.6.2",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^7.0.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"vite": "^7.1.9",
|
||||
"vitest": "^3.2.4",
|
||||
},
|
||||
},
|
||||
@@ -67,17 +67,31 @@
|
||||
|
||||
"@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.0", "", { "dependencies": { "@babel/parser": "^7.28.0", "@babel/types": "^7.28.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg=="],
|
||||
|
||||
"@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-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.0", "", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="],
|
||||
"@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=="],
|
||||
|
||||
@@ -87,7 +101,7 @@
|
||||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
|
||||
|
||||
"@date-fns/tz": ["@date-fns/tz@1.2.0", "", {}, "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg=="],
|
||||
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
|
||||
|
||||
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||
|
||||
@@ -149,23 +163,23 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.3.0", "", {}, "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw=="],
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0" } }, "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@0.15.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA=="],
|
||||
"@eslint/core": ["@eslint/core@0.16.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q=="],
|
||||
|
||||
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@9.32.0", "", {}, "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg=="],
|
||||
"@eslint/js": ["@eslint/js@9.37.0", "", {}, "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.4", "", { "dependencies": { "@eslint/core": "^0.15.1", "levn": "^0.4.1" } }, "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw=="],
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0", "levn": "^0.4.1" } }, "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.2", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw=="],
|
||||
|
||||
@@ -191,6 +205,8 @@
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="],
|
||||
|
||||
"@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.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="],
|
||||
@@ -209,13 +225,13 @@
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
|
||||
"@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-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@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-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="],
|
||||
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "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-presence": "1.1.5", "@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-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -223,17 +239,17 @@
|
||||
|
||||
"@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-context-menu": ["@radix-ui/react-context-menu@2.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.15", "@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-UsQUMjcYTsBjTSXw0P3GO0werEQvUY2plgRQuKoCTtkNr45q1DiL51j4m7gxhABzZ0BadoXNsIbg7F3KwiUBbw=="],
|
||||
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@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-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="],
|
||||
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@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.4", "@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-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
|
||||
"@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.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@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-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
|
||||
"@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.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@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.15", "@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-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ=="],
|
||||
"@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.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
|
||||
"@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=="],
|
||||
|
||||
@@ -241,37 +257,37 @@
|
||||
|
||||
"@radix-ui/react-label": ["@radix-ui/react-label@2.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-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@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.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@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-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew=="],
|
||||
"@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-popover": ["@radix-ui/react-popover@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@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-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw=="],
|
||||
"@radix-ui/react-popover": ["@radix-ui/react-popover@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-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", "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-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "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-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
|
||||
"@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.4", "", { "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-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
|
||||
"@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-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@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-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@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-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
|
||||
"@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.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@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.4", "@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-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="],
|
||||
"@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.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@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.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@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-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
|
||||
"@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-slider": ["@radix-ui/react-slider@1.3.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@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-primitive": "2.1.3", "@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-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-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw=="],
|
||||
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.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-primitive": "2.1.3", "@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-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-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
|
||||
|
||||
"@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.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@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-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ=="],
|
||||
"@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.12", "", { "dependencies": { "@radix-ui/primitive": "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-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@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-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw=="],
|
||||
"@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.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@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-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw=="],
|
||||
"@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=="],
|
||||
|
||||
@@ -295,7 +311,7 @@
|
||||
|
||||
"@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=="],
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.35", "", {}, "sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.45.1", "", { "os": "android", "cpu": "arm" }, "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA=="],
|
||||
|
||||
@@ -339,61 +355,61 @@
|
||||
|
||||
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
||||
|
||||
"@swc/core": ["@swc/core@1.13.2", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.23" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.13.2", "@swc/core-darwin-x64": "1.13.2", "@swc/core-linux-arm-gnueabihf": "1.13.2", "@swc/core-linux-arm64-gnu": "1.13.2", "@swc/core-linux-arm64-musl": "1.13.2", "@swc/core-linux-x64-gnu": "1.13.2", "@swc/core-linux-x64-musl": "1.13.2", "@swc/core-win32-arm64-msvc": "1.13.2", "@swc/core-win32-ia32-msvc": "1.13.2", "@swc/core-win32-x64-msvc": "1.13.2" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-YWqn+0IKXDhqVLKoac4v2tV6hJqB/wOh8/Br8zjqeqBkKa77Qb0Kw2i7LOFzjFNZbZaPH6AlMGlBwNrxaauaAg=="],
|
||||
"@swc/core": ["@swc/core@1.13.5", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.24" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.13.5", "@swc/core-darwin-x64": "1.13.5", "@swc/core-linux-arm-gnueabihf": "1.13.5", "@swc/core-linux-arm64-gnu": "1.13.5", "@swc/core-linux-arm64-musl": "1.13.5", "@swc/core-linux-x64-gnu": "1.13.5", "@swc/core-linux-x64-musl": "1.13.5", "@swc/core-win32-arm64-msvc": "1.13.5", "@swc/core-win32-ia32-msvc": "1.13.5", "@swc/core-win32-x64-msvc": "1.13.5" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ=="],
|
||||
|
||||
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.13.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-44p7ivuLSGFJ15Vly4ivLJjg3ARo4879LtEBAabcHhSZygpmkP8eyjyWxrH3OxkY1eRZSIJe8yRZPFw4kPXFPw=="],
|
||||
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.13.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ=="],
|
||||
|
||||
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.13.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Lb9EZi7X2XDAVmuUlBm2UvVAgSCbD3qKqDCxSI4jEOddzVOpNCnyZ/xEampdngUIyDDhhJLYU9duC+Mcsv5Y+A=="],
|
||||
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.13.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng=="],
|
||||
|
||||
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.13.2", "", { "os": "linux", "cpu": "arm" }, "sha512-9TDe/92ee1x57x+0OqL1huG4BeljVx0nWW4QOOxp8CCK67Rpc/HHl2wciJ0Kl9Dxf2NvpNtkPvqj9+BUmM9WVA=="],
|
||||
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.13.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ=="],
|
||||
|
||||
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.13.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJUSl56DBk7AWMAIEcU83zl5mg3vlQYhLELhjwRFkGFMvghQvdqQ3zFOYa4TexKA7noBZa3C8fb24rI5sw9Exg=="],
|
||||
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw=="],
|
||||
|
||||
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.13.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-teU27iG1oyWpNh9CzcGQ48ClDRt/RCem7mYO7ehd2FY102UeTws2+OzLESS1TS1tEZipq/5xwx3FzbVgiolCiQ=="],
|
||||
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ=="],
|
||||
|
||||
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.13.2", "", { "os": "linux", "cpu": "x64" }, "sha512-dRPsyPyqpLD0HMRCRpYALIh4kdOir8pPg4AhNQZLehKowigRd30RcLXGNVZcc31Ua8CiPI4QSgjOIxK+EQe4LQ=="],
|
||||
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA=="],
|
||||
|
||||
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.13.2", "", { "os": "linux", "cpu": "x64" }, "sha512-CCxETW+KkYEQDqz1SYC15YIWYheqFC+PJVOW76Maa/8yu8Biw+HTAcblKf2isrlUtK8RvrQN94v3UXkC2NzCEw=="],
|
||||
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q=="],
|
||||
|
||||
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.13.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wv/QTA6PjyRLlmKcN6AmSI4jwSMRl0VTLGs57PHTqYRwwfwd7y4s2fIPJVBNbAlXd795dOEP6d/bGSQSyhOX3A=="],
|
||||
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.13.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw=="],
|
||||
|
||||
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.13.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-PuCdtNynEkUNbUXX/wsyUC+t4mamIU5y00lT5vJcAvco3/r16Iaxl5UCzhXYaWZSNVZMzPp9qN8NlSL8M5pPxw=="],
|
||||
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.13.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw=="],
|
||||
|
||||
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.13.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qlmMkFZJus8cYuBURx1a3YAG2G7IW44i+FEYV5/32ylKkzGNAr9tDJSA53XNnNXkAB5EXSPsOz7bn5C3JlEtdQ=="],
|
||||
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.13.5", "", { "os": "win32", "cpu": "x64" }, "sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q=="],
|
||||
|
||||
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
|
||||
|
||||
"@swc/types": ["@swc/types@0.1.23", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw=="],
|
||||
"@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="],
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.1", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.14" } }, "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.11", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.11", "@tailwindcss/oxide-darwin-arm64": "4.1.11", "@tailwindcss/oxide-darwin-x64": "4.1.11", "@tailwindcss/oxide-freebsd-x64": "4.1.11", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", "@tailwindcss/oxide-linux-x64-musl": "4.1.11", "@tailwindcss/oxide-wasm32-wasi": "4.1.11", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" } }, "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg=="],
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.14", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.5.1" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.14", "@tailwindcss/oxide-darwin-arm64": "4.1.14", "@tailwindcss/oxide-darwin-x64": "4.1.14", "@tailwindcss/oxide-freebsd-x64": "4.1.14", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14", "@tailwindcss/oxide-linux-arm64-musl": "4.1.14", "@tailwindcss/oxide-linux-x64-gnu": "4.1.14", "@tailwindcss/oxide-linux-x64-musl": "4.1.14", "@tailwindcss/oxide-wasm32-wasi": "4.1.14", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14", "@tailwindcss/oxide-win32-x64-msvc": "4.1.14" } }, "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.11", "", { "os": "android", "cpu": "arm64" }, "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg=="],
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.14", "", { "os": "android", "cpu": "arm64" }, "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ=="],
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw=="],
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA=="],
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11", "", { "os": "linux", "cpu": "arm" }, "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg=="],
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14", "", { "os": "linux", "cpu": "arm" }, "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ=="],
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ=="],
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg=="],
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q=="],
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.11", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.14", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.5", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w=="],
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.11", "", { "os": "win32", "cpu": "x64" }, "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg=="],
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.14", "", { "os": "win32", "cpu": "x64" }, "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="],
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.14", "", { "dependencies": { "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "tailwindcss": "4.1.14" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA=="],
|
||||
|
||||
"@trivago/prettier-plugin-sort-imports": ["@trivago/prettier-plugin-sort-imports@5.2.2", "", { "dependencies": { "@babel/generator": "^7.26.5", "@babel/parser": "^7.26.7", "@babel/traverse": "^7.26.7", "@babel/types": "^7.26.7", "javascript-natural-sort": "^0.7.1", "lodash": "^4.17.21" }, "peerDependencies": { "@vue/compiler-sfc": "3.x", "prettier": "2.x - 3.x", "prettier-plugin-svelte": "3.x", "svelte": "4.x || 5.x" }, "optionalPeers": ["@vue/compiler-sfc", "prettier-plugin-svelte", "svelte"] }, "sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA=="],
|
||||
|
||||
@@ -423,33 +439,33 @@
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
|
||||
"@types/node": ["@types/node@24.6.2", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
|
||||
"@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="],
|
||||
"@types/react-dom": ["@types/react-dom@19.2.0", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.38.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/type-utils": "8.38.0", "@typescript-eslint/utils": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.38.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA=="],
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.45.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/type-utils": "8.45.0", "@typescript-eslint/utils": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.45.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.38.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ=="],
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.45.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.38.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.38.0", "@typescript-eslint/types": "^8.38.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg=="],
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.45.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.45.0", "@typescript-eslint/types": "^8.45.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0" } }, "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ=="],
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0" } }, "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.38.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ=="],
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.45.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/utils": "8.38.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg=="],
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0", "@typescript-eslint/utils": "8.45.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.38.0", "", {}, "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw=="],
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.45.0", "", {}, "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.38.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.38.0", "@typescript-eslint/tsconfig-utils": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ=="],
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.45.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.45.0", "@typescript-eslint/tsconfig-utils": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.38.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg=="],
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.45.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g=="],
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag=="],
|
||||
|
||||
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.11.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.27", "@swc/core": "^1.12.11" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w=="],
|
||||
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@4.1.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.35", "@swc/core": "^1.13.5" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-Ff690TUck0Anlh7wdIcnsVMhofeEVgm44Y4OYdeeEEPSKyZHzDI9gfVBvySEhDfXtBp8tLCbfsVKPWEMEjq8/g=="],
|
||||
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "ast-v8-to-istanbul": "^0.3.3", "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.2.4", "vitest": "3.2.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="],
|
||||
|
||||
@@ -487,14 +503,20 @@
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-i+sRXGhz4+QW8aACZ3+r1GAKMt0wlFpeA8M5rOQd0HEYw9zhDrlx9Wc8uQ0IdXakjJRthzglEwfB/yqIjO6iDg=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001747", "", {}, "sha512-mzFa2DGIhuc5490Nd/G31xN1pnBnYMadtkyTjefPI7wzypqgCEpeWu9bJr0OnDsyKrW75zA9ZAt7pbQFmwLsQg=="],
|
||||
|
||||
"chai": ["chai@5.2.1", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
@@ -515,6 +537,8 @@
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"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=="],
|
||||
@@ -563,25 +587,29 @@
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.230", "", {}, "sha512-A6A6Fd3+gMdaed9wX83CvHYJb4UuapPD5X5SLq72VZJzxHSY0/LUweGXRWmQlh2ln7KV7iw7jnwXK7dlPoOnHQ=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"engine.io-client": ["engine.io-client@6.6.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w=="],
|
||||
|
||||
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="],
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@9.32.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.0", "@eslint/core": "^0.15.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.32.0", "@eslint/plugin-kit": "^0.3.4", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg=="],
|
||||
"eslint": ["eslint@9.37.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.4.0", "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.37.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig=="],
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@6.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "zod": "^3.22.4 || ^4.0.0", "zod-validation-error": "^3.0.3 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-St9EKZzOAQF704nt2oJvAKZHjhrpg25ClQoaAlHmPZuajFldVLqRDW4VBNAS01NzeiQF0m0qhG1ZA807K6aVaQ=="],
|
||||
|
||||
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.20", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA=="],
|
||||
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.23", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
@@ -607,7 +635,7 @@
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="],
|
||||
"fast-equals": ["fast-equals@5.3.2", "", {}, "sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -617,7 +645,7 @@
|
||||
|
||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||
|
||||
"fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
@@ -633,13 +661,15 @@
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"globals": ["globals@16.3.0", "", {}, "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ=="],
|
||||
"globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
@@ -693,6 +723,8 @@
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
@@ -729,9 +761,9 @@
|
||||
|
||||
"loupe": ["loupe@3.2.0", "", {}, "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw=="],
|
||||
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.539.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg=="],
|
||||
"lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||
|
||||
@@ -747,9 +779,7 @@
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
|
||||
|
||||
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
|
||||
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
@@ -759,6 +789,8 @@
|
||||
|
||||
"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.23", "", {}, "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg=="],
|
||||
|
||||
"number-flow": ["number-flow@0.5.8", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-FPr1DumWyGi5Nucoug14bC6xEz70A1TnhgSHhKyfqjgji2SOTz+iLJxKtv37N5JyJbteGYCm6NQ9p1O4KZ7iiA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
@@ -799,11 +831,11 @@
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
|
||||
|
||||
"react-day-picker": ["react-day-picker@9.8.1", "", { "dependencies": { "@date-fns/tz": "^1.2.0", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-kMcLrp3PfN/asVJayVv82IjF3iLOOxuH5TNFWezX6lS/T8iVRFPTETpHl3TUSTH99IDMZLubdNPJr++rQctkEw=="],
|
||||
"react-day-picker": ["react-day-picker@9.11.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-L4FYOaPrr3+AEROeP6IG2mCORZZfxJDkJI2df8mv1jyPrNYeccgmFPZDaHyAuPCBCddQFozkxbikj2NhMEYfDQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
|
||||
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
|
||||
|
||||
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
@@ -811,7 +843,7 @@
|
||||
|
||||
"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.7.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-jVKHXoWRIsD/qS6lvGveckwb862EekvapdHJN/cGmzw40KnJH5gg53ujOJ4qX6EKIK9LSBfFed/xiQ5yeXNrUA=="],
|
||||
"react-router": ["react-router@7.9.3", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg=="],
|
||||
|
||||
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
|
||||
|
||||
@@ -831,9 +863,9 @@
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
"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=="],
|
||||
|
||||
@@ -873,11 +905,11 @@
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
"tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="],
|
||||
|
||||
"tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
|
||||
|
||||
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
|
||||
"tar": ["tar@7.5.1", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g=="],
|
||||
|
||||
"test-exclude": ["test-exclude@7.0.1", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^9.0.4" } }, "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg=="],
|
||||
|
||||
@@ -887,7 +919,7 @@
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
|
||||
|
||||
@@ -901,15 +933,17 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.3.6", "", {}, "sha512-9dy0R9UsYEGmgf26L8UcHiLmSFTHa9+D7+dAt/G/sF5dCnPePZbfgDYinc7/UzAM7g/baVrmS6m9yEpU46d+LA=="],
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.38.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.38.0", "@typescript-eslint/parser": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/utils": "8.38.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg=="],
|
||||
"typescript-eslint": ["typescript-eslint@8.45.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.45.0", "@typescript-eslint/parser": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0", "@typescript-eslint/utils": "8.45.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg=="],
|
||||
|
||||
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
|
||||
"undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
@@ -921,7 +955,7 @@
|
||||
|
||||
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
||||
|
||||
"vite": ["vite@7.0.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" }, "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-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg=="],
|
||||
"vite": ["vite@7.1.9", "", { "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-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg=="],
|
||||
|
||||
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||
|
||||
@@ -945,23 +979,47 @@
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"@babel/core/@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/core/@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
|
||||
|
||||
"@babel/core/@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/core/@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=="],
|
||||
|
||||
"@babel/helper-module-imports/@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/helper-module-imports/@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=="],
|
||||
|
||||
"@babel/helper-module-transforms/@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/helpers/@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=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
|
||||
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" }, "bundled": true }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
|
||||
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
|
||||
"@tailwindcss/node/magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" }, "bundled": true }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
@@ -969,6 +1027,10 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@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.4", "@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-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
|
||||
|
||||
"engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
@@ -977,8 +1039,14 @@
|
||||
|
||||
"loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"make-dir/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||
@@ -993,22 +1061,54 @@
|
||||
|
||||
"test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"vite-node/vite": ["vite@7.0.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" }, "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-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg=="],
|
||||
|
||||
"vitest/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"vitest/vite": ["vite@7.0.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" }, "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-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg=="],
|
||||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
|
||||
"@babel/helper-module-imports/@babel/traverse/@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-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
|
||||
|
||||
"@babel/helper-module-transforms/@babel/traverse/@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-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
|
||||
|
||||
"@babel/helper-module-transforms/@babel/traverse/@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=="],
|
||||
|
||||
"@tailwindcss/node/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@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-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "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-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"test-exclude/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"vite-node/vite/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||
|
||||
"vite-node/vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"vitest/tinyglobby/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||
|
||||
"vitest/vite/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
62
package.json
62
package.json
@@ -16,55 +16,55 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@number-flow/react": "^0.5.10",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-context-menu": "^2.2.15",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@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-slider": "^1.3.5",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.539.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-day-picker": "^9.8.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router": "^7.7.1",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^9.11.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router": "^7.9.3",
|
||||
"recharts": "2.15.4",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11"
|
||||
"tailwindcss": "^4.1.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"@types/node": "^24.6.2",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-plugin-react-hooks": "^6.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.23",
|
||||
"globals": "^16.4.0",
|
||||
"prettier": "^3.6.2",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^7.0.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"vite": "^7.1.9",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
54
src/App.tsx
54
src/App.tsx
@@ -1,22 +1,38 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { Navigate, Route, Routes } from 'react-router'
|
||||
import { LocaleProvider } from './components/LocaleProvider'
|
||||
import { ThemeProvider } from './components/ThemeProvider'
|
||||
import { Toaster } from './components/ui/sonner'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { SocketProvider } from './contexts/SocketContext'
|
||||
import { AccountPage } from './pages/AccountPage'
|
||||
import { AuthCallbackPage } from './pages/AuthCallbackPage'
|
||||
import { DashboardPage } from './pages/DashboardPage'
|
||||
import { ExtractionsPage } from './pages/ExtractionsPage'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { PlaylistEditPage } from './pages/PlaylistEditPage'
|
||||
import { PlaylistsPage } from './pages/PlaylistsPage'
|
||||
import { RegisterPage } from './pages/RegisterPage'
|
||||
import { SchedulersPage } from './pages/SchedulersPage'
|
||||
import { SequencerPage } from './pages/SequencerPage'
|
||||
import { SoundsPage } from './pages/SoundsPage'
|
||||
import { SettingsPage } from './pages/admin/SettingsPage'
|
||||
import { UsersPage } from './pages/admin/UsersPage'
|
||||
|
||||
// Lazy load all pages for code splitting
|
||||
const AccountPage = lazy(() => import('./pages/AccountPage').then(m => ({ default: m.AccountPage })))
|
||||
const AuthCallbackPage = lazy(() => import('./pages/AuthCallbackPage').then(m => ({ default: m.AuthCallbackPage })))
|
||||
const DashboardPage = lazy(() => import('./pages/DashboardPage').then(m => ({ default: m.DashboardPage })))
|
||||
const ExtractionsPage = lazy(() => import('./pages/ExtractionsPage').then(m => ({ default: m.ExtractionsPage })))
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage').then(m => ({ default: m.LoginPage })))
|
||||
const PlaylistEditPage = lazy(() => import('./pages/PlaylistEditPage').then(m => ({ default: m.PlaylistEditPage })))
|
||||
const PlaylistsPage = lazy(() => import('./pages/PlaylistsPage').then(m => ({ default: m.PlaylistsPage })))
|
||||
const RegisterPage = lazy(() => import('./pages/RegisterPage').then(m => ({ default: m.RegisterPage })))
|
||||
const SchedulersPage = lazy(() => import('./pages/SchedulersPage').then(m => ({ default: m.SchedulersPage })))
|
||||
const SequencerPage = lazy(() => import('./pages/SequencerPage').then(m => ({ default: m.SequencerPage })))
|
||||
const SoundsPage = lazy(() => import('./pages/SoundsPage').then(m => ({ default: m.SoundsPage })))
|
||||
const TTSPage = lazy(() => import('./pages/TTSPage').then(m => ({ default: m.TTSPage })))
|
||||
const SettingsPage = lazy(() => import('./pages/admin/SettingsPage').then(m => ({ default: m.SettingsPage })))
|
||||
const UsersPage = lazy(() => import('./pages/admin/UsersPage').then(m => ({ default: m.UsersPage })))
|
||||
|
||||
// Loading component for lazy-loaded routes
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
@@ -62,6 +78,7 @@ function AppRoutes() {
|
||||
const { user } = useAuth()
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
@@ -112,6 +129,14 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tts"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TTSPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/sequencer"
|
||||
element={
|
||||
@@ -153,6 +178,7 @@ function AppRoutes() {
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,7 +189,7 @@ function App() {
|
||||
<AuthProvider>
|
||||
<SocketProvider>
|
||||
<AppRoutes />
|
||||
<Toaster richColors position='top-right' />
|
||||
<Toaster richColors position='top-center' />
|
||||
</SocketProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AppSidebar } from './AppSidebar'
|
||||
import { GlobalSearch } from './GlobalSearch'
|
||||
import { Player, type PlayerDisplayMode } from './player/Player'
|
||||
|
||||
interface AppLayoutProps {
|
||||
@@ -43,11 +44,29 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
|
||||
},
|
||||
)
|
||||
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||
|
||||
// Handle keyboard shortcut for global search
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||
e.preventDefault()
|
||||
setIsSearchOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// Note: localStorage is managed by the Player component
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar showCompactPlayer={playerDisplayMode === 'sidebar'} />
|
||||
<AppSidebar
|
||||
showCompactPlayer={playerDisplayMode === 'sidebar'}
|
||||
onSearchClick={() => setIsSearchOpen(true)}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
@@ -80,6 +99,7 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">{children}</div>
|
||||
</SidebarInset>
|
||||
<Player onPlayerModeChange={setPlayerDisplayMode} />
|
||||
<GlobalSearch isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
Settings,
|
||||
Users,
|
||||
AudioLines,
|
||||
Mic,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import { CreditsNav } from './nav/CreditsNav'
|
||||
import { NavGroup } from './nav/NavGroup'
|
||||
@@ -26,9 +29,10 @@ import { CompactPlayer } from './player/CompactPlayer'
|
||||
|
||||
interface AppSidebarProps {
|
||||
showCompactPlayer?: boolean
|
||||
onSearchClick?: () => void
|
||||
}
|
||||
|
||||
export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
|
||||
export function AppSidebar({ showCompactPlayer = false, onSearchClick }: AppSidebarProps) {
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
if (!user) return null
|
||||
@@ -45,13 +49,28 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<div className="px-2 py-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2 group-data-[collapsible=icon]:justify-center"
|
||||
onClick={onSearchClick}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">Search</span>
|
||||
<kbd className="ml-auto pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100 group-data-[collapsible=icon]:hidden">
|
||||
<span className="text-xs">⌘</span>F
|
||||
</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<NavGroup label="Application">
|
||||
<NavItem href="/" icon={Home} title="Dashboard" />
|
||||
<NavItem href="/sounds" icon={Music} title="Sounds" />
|
||||
<NavItem href="/playlists" icon={PlayCircle} title="Playlists" />
|
||||
<NavItem href="/sequencer" icon={AudioLines} title="Sequencer" />
|
||||
<NavItem href="/extractions" icon={Download} title="Extractions" />
|
||||
<NavItem href="/tts" icon={Mic} title="Text to Speech" />
|
||||
<NavItem href="/schedulers" icon={CalendarClock} title="Schedulers" />
|
||||
<NavItem href="/sequencer" icon={AudioLines} title="Sequencer (WIP)" />
|
||||
</NavGroup>
|
||||
|
||||
{user.role === 'admin' && (
|
||||
|
||||
402
src/components/GlobalSearch.tsx
Normal file
402
src/components/GlobalSearch.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import { playerService } from '@/lib/api/services/player'
|
||||
import { playlistsService } from '@/lib/api/services/playlists'
|
||||
import { soundsService, type Sound } from '@/lib/api/services/sounds'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import { Music, PlayCircle, Search, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface GlobalSearchProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type SearchResultType = 'sound' | 'playlist' | 'playlist-track'
|
||||
|
||||
interface SearchResult {
|
||||
id: string
|
||||
type: SearchResultType
|
||||
title: string
|
||||
subtitle?: string
|
||||
duration?: number
|
||||
thumbnail?: string
|
||||
soundType?: 'SDB' | 'TTS' | 'EXT'
|
||||
playlistId?: number
|
||||
trackIndex?: number
|
||||
}
|
||||
|
||||
export function GlobalSearch({ isOpen, onClose }: GlobalSearchProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [currentPlaylistTracks, setCurrentPlaylistTracks] = useState<Sound[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSearchQuery('')
|
||||
setResults([])
|
||||
setCurrentPlaylistTracks([])
|
||||
} else {
|
||||
// Load current playlist tracks when opening
|
||||
loadCurrentPlaylistTracks()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery.trim()) {
|
||||
performSearch()
|
||||
} else {
|
||||
setResults([])
|
||||
}
|
||||
}, [searchQuery])
|
||||
|
||||
const loadCurrentPlaylistTracks = async () => {
|
||||
try {
|
||||
const state = await playerService.getState()
|
||||
if (state.playlist) {
|
||||
setCurrentPlaylistTracks(state.playlist.sounds as unknown as Sound[])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load current playlist tracks:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const performSearch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
const newResults: SearchResult[] = []
|
||||
|
||||
// Search sounds (SDB and TTS)
|
||||
const sounds = await soundsService.getSounds({
|
||||
search: query,
|
||||
types: ['SDB', 'TTS'],
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
sounds.forEach((sound) => {
|
||||
newResults.push({
|
||||
id: `sound-${sound.id}`,
|
||||
type: 'sound',
|
||||
title: sound.name,
|
||||
subtitle: sound.type,
|
||||
duration: sound.duration,
|
||||
thumbnail: sound.thumbnail,
|
||||
soundType: sound.type,
|
||||
})
|
||||
})
|
||||
|
||||
// Search playlists
|
||||
const playlistsResponse = await playlistsService.getPlaylists({
|
||||
search: query,
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
playlistsResponse.playlists.forEach((playlist) => {
|
||||
newResults.push({
|
||||
id: `playlist-${playlist.id}`,
|
||||
type: 'playlist',
|
||||
title: playlist.name,
|
||||
subtitle: `${playlist.sound_count} tracks`,
|
||||
duration: playlist.total_duration,
|
||||
playlistId: playlist.id,
|
||||
})
|
||||
})
|
||||
|
||||
// Search current playlist tracks
|
||||
currentPlaylistTracks.forEach((track, index) => {
|
||||
if (track.name.toLowerCase().includes(query)) {
|
||||
newResults.push({
|
||||
id: `track-${track.id}-${index}`,
|
||||
type: 'playlist-track',
|
||||
title: track.name,
|
||||
subtitle: 'Current playlist',
|
||||
duration: track.duration,
|
||||
thumbnail: track.thumbnail,
|
||||
trackIndex: index,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
setResults(newResults)
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
toast.error('Search failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResultClick = async (result: SearchResult) => {
|
||||
try {
|
||||
if (result.type === 'sound') {
|
||||
const soundId = parseInt(result.id.replace('sound-', ''))
|
||||
await soundsService.playSound(soundId)
|
||||
toast.success(`Playing ${result.title}`)
|
||||
} else if (result.type === 'playlist' && result.playlistId) {
|
||||
await playlistsService.setCurrentPlaylist(result.playlistId)
|
||||
toast.success(`Set ${result.title} as current playlist`)
|
||||
} else if (result.type === 'playlist-track' && result.trackIndex !== undefined) {
|
||||
await playerService.playAtIndex(result.trackIndex)
|
||||
toast.success(`Playing ${result.title}`)
|
||||
}
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error)
|
||||
toast.error('Action failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle global escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleGlobalKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleGlobalKeyDown)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="fixed top-[20%] left-1/2 -translate-x-1/2 w-full max-w-2xl bg-background border rounded-lg shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div className="relative p-4 border-b">
|
||||
<Search className="absolute left-6 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search sounds, playlists, or tracks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="pl-10 pr-10"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<ScrollArea className="h-[60vh]">
|
||||
<div className="p-2">
|
||||
{loading && (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && searchQuery && results.length === 0 && (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{/* Sounds Section */}
|
||||
{results.filter((r) => r.type === 'sound').length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Sounds ({results.filter((r) => r.type === 'sound').length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{results
|
||||
.filter((r) => r.type === 'sound')
|
||||
.map((result) => (
|
||||
<button
|
||||
key={result.id}
|
||||
className="w-full flex items-center gap-3 p-2 rounded hover:bg-muted/50 text-left transition-colors"
|
||||
onClick={() => handleResultClick(result)}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{result.thumbnail ? (
|
||||
<div className="w-10 h-10 rounded bg-muted overflow-hidden">
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(
|
||||
parseInt(result.id.split('-')[1])
|
||||
)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
|
||||
<Music className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{result.title}</div>
|
||||
{result.subtitle && (
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{result.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
{result.soundType && (
|
||||
<Badge
|
||||
variant={
|
||||
result.soundType === 'SDB' ? 'default' : 'secondary'
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{result.soundType}
|
||||
</Badge>
|
||||
)}
|
||||
{result.duration && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDuration(result.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playlists Section */}
|
||||
{results.filter((r) => r.type === 'playlist').length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Playlists ({results.filter((r) => r.type === 'playlist').length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{results
|
||||
.filter((r) => r.type === 'playlist')
|
||||
.map((result) => (
|
||||
<button
|
||||
key={result.id}
|
||||
className="w-full flex items-center gap-3 p-2 rounded hover:bg-muted/50 text-left transition-colors"
|
||||
onClick={() => handleResultClick(result)}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
|
||||
<PlayCircle className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{result.title}</div>
|
||||
{result.subtitle && (
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{result.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
{result.duration && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDuration(result.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playlist Tracks Section */}
|
||||
{results.filter((r) => r.type === 'playlist-track').length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Current Playlist ({results.filter((r) => r.type === 'playlist-track').length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{results
|
||||
.filter((r) => r.type === 'playlist-track')
|
||||
.map((result) => (
|
||||
<button
|
||||
key={result.id}
|
||||
className="w-full flex items-center gap-3 p-2 rounded hover:bg-muted/50 text-left transition-colors"
|
||||
onClick={() => handleResultClick(result)}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{result.thumbnail ? (
|
||||
<div className="w-10 h-10 rounded bg-muted overflow-hidden">
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(
|
||||
parseInt(result.id.split('-')[1])
|
||||
)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
|
||||
<Music className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{result.title}</div>
|
||||
{result.subtitle && (
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{result.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
{result.duration && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDuration(result.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searchQuery && (
|
||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||
Type to search sounds, playlists, or tracks
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-2 border-t bg-muted/30">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-2">
|
||||
<span>Press ESC to close</span>
|
||||
{results.length > 0 && <span>{results.length} results</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { StatisticCard } from '@/components/dashboard/StatisticCard'
|
||||
import { NumberFlowDuration } from '@/components/ui/number-flow-duration'
|
||||
import { NumberFlowSize } from '@/components/ui/number-flow-size'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { Clock, HardDrive, Music, Play, Volume2 } from 'lucide-react'
|
||||
import { Clock, HardDrive, Music, Play, Volume2, MessageSquare } from 'lucide-react'
|
||||
|
||||
interface SoundboardStatistics {
|
||||
sound_count: number
|
||||
@@ -18,12 +18,20 @@ interface TrackStatistics {
|
||||
total_size: number
|
||||
}
|
||||
|
||||
interface TTSStatistics {
|
||||
sound_count: number
|
||||
total_play_count: number
|
||||
total_duration: number
|
||||
total_size: number
|
||||
}
|
||||
|
||||
interface StatisticsGridProps {
|
||||
soundboardStatistics: SoundboardStatistics
|
||||
trackStatistics: TrackStatistics
|
||||
ttsStatistics: TTSStatistics
|
||||
}
|
||||
|
||||
export function StatisticsGrid({ soundboardStatistics, trackStatistics }: StatisticsGridProps) {
|
||||
export function StatisticsGrid({ soundboardStatistics, trackStatistics, ttsStatistics }: StatisticsGridProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -109,6 +117,48 @@ export function StatisticsGrid({ soundboardStatistics, trackStatistics }: Statis
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
|
||||
TTS Statistics
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatisticCard
|
||||
title="Total TTS"
|
||||
icon={MessageSquare}
|
||||
value={<NumberFlow value={ttsStatistics.sound_count} />}
|
||||
description="Text-to-speech audio files"
|
||||
/>
|
||||
<StatisticCard
|
||||
title="Total Plays"
|
||||
icon={Play}
|
||||
value={<NumberFlow value={ttsStatistics.total_play_count} />}
|
||||
description="All-time play count"
|
||||
/>
|
||||
<StatisticCard
|
||||
title="Total Duration"
|
||||
icon={Clock}
|
||||
value={
|
||||
<NumberFlowDuration
|
||||
duration={ttsStatistics.total_duration}
|
||||
variant="wordy"
|
||||
/>
|
||||
}
|
||||
description="Combined TTS duration"
|
||||
/>
|
||||
<StatisticCard
|
||||
title="Total Size"
|
||||
icon={HardDrive}
|
||||
value={
|
||||
<NumberFlowSize
|
||||
size={ttsStatistics.total_size}
|
||||
binary={true}
|
||||
/>
|
||||
}
|
||||
description="Original + normalized files"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
162
src/components/dashboard/TopUsersSection.tsx
Normal file
162
src/components/dashboard/TopUsersSection.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { Loader2, Trophy, User } from 'lucide-react'
|
||||
|
||||
interface TopUser {
|
||||
id: number
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface TopUsersSectionProps {
|
||||
topUsers: TopUser[]
|
||||
loading: boolean
|
||||
metricType: string
|
||||
period: string
|
||||
limit: number
|
||||
onMetricTypeChange: (value: string) => void
|
||||
onPeriodChange: (value: string) => void
|
||||
onLimitChange: (value: number) => void
|
||||
}
|
||||
|
||||
const metricTypeLabels = {
|
||||
sounds_played: 'Sounds Played',
|
||||
credits_used: 'Credits Used',
|
||||
tracks_added: 'Tracks Added',
|
||||
tts_added: 'TTS Added',
|
||||
playlists_created: 'Playlists Created',
|
||||
}
|
||||
|
||||
const metricTypeUnits = {
|
||||
sounds_played: 'plays',
|
||||
credits_used: 'credits',
|
||||
tracks_added: 'tracks',
|
||||
tts_added: 'TTS',
|
||||
playlists_created: 'playlists',
|
||||
}
|
||||
|
||||
export function TopUsersSection({
|
||||
topUsers,
|
||||
loading,
|
||||
metricType,
|
||||
period,
|
||||
limit,
|
||||
onMetricTypeChange,
|
||||
onPeriodChange,
|
||||
onLimitChange,
|
||||
}: TopUsersSectionProps) {
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5" />
|
||||
<CardTitle>Top Users</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Metric:</span>
|
||||
<Select value={metricType} onValueChange={onMetricTypeChange}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="credits_used">Credits Used</SelectItem>
|
||||
<SelectItem value="playlists_created">Playlists Created</SelectItem>
|
||||
<SelectItem value="sounds_played">Sounds Played</SelectItem>
|
||||
<SelectItem value="tracks_added">Tracks Added</SelectItem>
|
||||
<SelectItem value="tts_added">TTS Added</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Period:</span>
|
||||
<Select value={period} onValueChange={onPeriodChange}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="today">Today</SelectItem>
|
||||
<SelectItem value="1_day">1 Day</SelectItem>
|
||||
<SelectItem value="1_week">1 Week</SelectItem>
|
||||
<SelectItem value="1_month">1 Month</SelectItem>
|
||||
<SelectItem value="1_year">1 Year</SelectItem>
|
||||
<SelectItem value="all_time">All Time</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Count:</span>
|
||||
<Select
|
||||
value={limit.toString()}
|
||||
onValueChange={value => onLimitChange(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="25">25</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||
Loading top users...
|
||||
</div>
|
||||
) : topUsers.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<User className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No users found for the selected criteria</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{topUsers.map((user, index) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center gap-4 p-3 bg-muted/30 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 bg-primary text-primary-foreground rounded-full font-bold text-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
<User className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{user.name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
<span className="px-1.5 py-0.5 bg-secondary rounded text-xs">
|
||||
{metricTypeLabels[metricType as keyof typeof metricTypeLabels]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
<NumberFlow value={user.count} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{metricTypeUnits[metricType as keyof typeof metricTypeUnits]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -93,11 +93,28 @@ export function ExtractionsHeader({
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={value => onStatusFilterChange(value as ExtractionStatus | 'all')}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="processing">Processing</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="failed">Failed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={value => onSortByChange(value as ExtractionSortField)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -122,23 +139,6 @@ export function ExtractionsHeader({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={value => onStatusFilterChange(value as ExtractionStatus | 'all')}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="processing">Processing</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="failed">Failed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
|
||||
@@ -99,6 +99,7 @@ export function ExtractionsRow({ extraction, onExtractionDeleted }: ExtractionsR
|
||||
tiktok: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
|
||||
twitter: 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300',
|
||||
instagram: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
|
||||
dailymotion: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300',
|
||||
}
|
||||
|
||||
const colorClass =
|
||||
@@ -117,10 +118,10 @@ export function ExtractionsRow({ extraction, onExtractionDeleted }: ExtractionsR
|
||||
<TableRow className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{extraction.title || 'Extracting...'}
|
||||
<div className="font-medium truncate max-w-80">
|
||||
{extraction.title || 'Processing...'}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground truncate max-w-64">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{extraction.url}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function StopSoundsButton() {
|
||||
onClick={handleStopSounds}
|
||||
disabled={isLoading}
|
||||
tooltip={tooltipText}
|
||||
className="group-data-[collapsible=icon]:justify-center text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-950/50"
|
||||
className="group-data-[collapsible=icon]:justify-center text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-950/50 cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<Square className="h-5 w-5 fill-current" />
|
||||
<span className="font-semibold group-data-[collapsible=icon]:hidden">
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import {
|
||||
type MessageResponse,
|
||||
type PlayerState,
|
||||
@@ -8,19 +5,11 @@ import {
|
||||
} from '@/lib/api/services/player'
|
||||
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Maximize2,
|
||||
Music,
|
||||
Pause,
|
||||
Play,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { NumberFlowDuration } from '../ui/number-flow-duration'
|
||||
import { CompactPlayerControls } from './CompactPlayerControls'
|
||||
import { CompactPlayerProgress } from './CompactPlayerProgress'
|
||||
import { CompactPlayerTrackInfo } from './CompactPlayerTrackInfo'
|
||||
|
||||
interface CompactPlayerProps {
|
||||
className?: string
|
||||
@@ -33,6 +22,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
volume: 80,
|
||||
previous_volume: 80,
|
||||
position: 0,
|
||||
play_next_queue: [],
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
@@ -107,146 +97,50 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
}
|
||||
}, [state.volume, executeAction])
|
||||
|
||||
// // Don't show if no current sound
|
||||
// if (!state.current_sound) {
|
||||
// return null
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
{/* Collapsed state - only play/pause button */}
|
||||
<div className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:justify-center hidden">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
title={state.status === 'playing' ? 'Pause' : 'Play'}
|
||||
>
|
||||
{state.status === 'playing' ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expanded state - full player */}
|
||||
<div className="group-data-[collapsible=icon]:hidden">
|
||||
{/* Track Info */}
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-muted rounded flex items-center justify-center overflow-hidden">
|
||||
{state.current_sound?.thumbnail ? (
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
alt={state.current_sound.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={e => {
|
||||
// Hide image and show music icon if thumbnail fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const musicIcon = target.nextElementSibling as HTMLElement
|
||||
if (musicIcon) musicIcon.style.display = 'block'
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Music
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground',
|
||||
state.current_sound?.thumbnail ? 'hidden' : 'block',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{state.current_sound?.name || 'No track selected'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{state.playlist?.name}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const handleExpand = useCallback(() => {
|
||||
const expandFn = (
|
||||
window as unknown as { __expandPlayerFromSidebar?: () => void }
|
||||
).__expandPlayerFromSidebar
|
||||
if (expandFn) expandFn()
|
||||
}}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
title="Expand Player"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
}, [])
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-3">
|
||||
<Progress
|
||||
value={(state.position / (state.duration || 1)) * 100}
|
||||
className="w-full h-1"
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
{/* Collapsed state - only play/pause button */}
|
||||
<CompactPlayerControls
|
||||
status={state.status}
|
||||
volume={state.volume}
|
||||
isLoading={isLoading}
|
||||
onPlayPause={handlePlayPause}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onVolumeToggle={handleVolumeToggle}
|
||||
variant="collapsed"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
||||
<span><NumberFlowDuration duration={state.position} /></span>
|
||||
<span><NumberFlowDuration duration={state.duration || 0} /></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handlePrevious}
|
||||
disabled={isLoading}
|
||||
className="h-7 w-7 p-0"
|
||||
title="Previous"
|
||||
>
|
||||
<SkipBack className="h-3 w-3" />
|
||||
</Button>
|
||||
{/* Expanded state - full player */}
|
||||
<div className="group-data-[collapsible=icon]:hidden">
|
||||
<CompactPlayerTrackInfo
|
||||
currentSound={state.current_sound}
|
||||
playlistName={state.playlist?.name}
|
||||
onExpand={handleExpand}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
title={state.status === 'playing' ? 'Pause' : 'Play'}
|
||||
>
|
||||
{state.status === 'playing' ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<CompactPlayerProgress
|
||||
position={state.position}
|
||||
duration={state.duration || 0}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleNext}
|
||||
disabled={isLoading}
|
||||
className="h-7 w-7 p-0"
|
||||
title="Next"
|
||||
>
|
||||
<SkipForward className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleVolumeToggle}
|
||||
className="h-7 w-7 p-0"
|
||||
title={state.volume === 0 ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{state.volume === 0 ? (
|
||||
<VolumeX className="h-3 w-3" />
|
||||
) : (
|
||||
<Volume2 className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<CompactPlayerControls
|
||||
status={state.status}
|
||||
volume={state.volume}
|
||||
isLoading={isLoading}
|
||||
onPlayPause={handlePlayPause}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onVolumeToggle={handleVolumeToggle}
|
||||
variant="expanded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
128
src/components/player/CompactPlayerControls.tsx
Normal file
128
src/components/player/CompactPlayerControls.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
import type { PlayerState } from '@/lib/api/services/player'
|
||||
import {
|
||||
Pause,
|
||||
Play,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import { memo } from 'react'
|
||||
|
||||
interface CompactPlayerControlsProps {
|
||||
status: PlayerState['status']
|
||||
volume: number
|
||||
isLoading: boolean
|
||||
onPlayPause: () => void
|
||||
onPrevious: () => void
|
||||
onNext: () => void
|
||||
onVolumeToggle: () => void
|
||||
variant?: 'collapsed' | 'expanded'
|
||||
}
|
||||
|
||||
export const CompactPlayerControls = memo(function CompactPlayerControls({
|
||||
status,
|
||||
volume,
|
||||
isLoading,
|
||||
onPlayPause,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onVolumeToggle,
|
||||
variant = 'expanded',
|
||||
}: CompactPlayerControlsProps) {
|
||||
const { isMobile, state: sidebarState } = useSidebar()
|
||||
|
||||
if (variant === 'collapsed') {
|
||||
return (
|
||||
<div className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:justify-center hidden">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onPlayPause}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{status === 'playing' ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={sidebarState !== "collapsed" || isMobile}
|
||||
>
|
||||
{status === 'playing' ? 'Pause' : 'Play'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Expanded variant
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onPrevious}
|
||||
disabled={isLoading}
|
||||
className="h-7 w-7 p-0"
|
||||
title="Previous"
|
||||
>
|
||||
<SkipBack className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onPlayPause}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
title={status === 'playing' ? 'Pause' : 'Play'}
|
||||
>
|
||||
{status === 'playing' ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onNext}
|
||||
disabled={isLoading}
|
||||
className="h-7 w-7 p-0"
|
||||
title="Next"
|
||||
>
|
||||
<SkipForward className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onVolumeToggle}
|
||||
className="h-7 w-7 p-0"
|
||||
title={volume === 0 ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{volume === 0 ? (
|
||||
<VolumeX className="h-3 w-3" />
|
||||
) : (
|
||||
<Volume2 className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
31
src/components/player/CompactPlayerProgress.tsx
Normal file
31
src/components/player/CompactPlayerProgress.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { NumberFlowDuration } from '../ui/number-flow-duration'
|
||||
|
||||
interface CompactPlayerProgressProps {
|
||||
position: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
export const CompactPlayerProgress = memo(function CompactPlayerProgress({
|
||||
position,
|
||||
duration,
|
||||
}: CompactPlayerProgressProps) {
|
||||
const progressPercentage = useMemo(() =>
|
||||
(position / (duration || 1)) * 100,
|
||||
[position, duration]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<Progress
|
||||
value={progressPercentage}
|
||||
className="w-full h-1"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
||||
<span><NumberFlowDuration duration={position} /></span>
|
||||
<span><NumberFlowDuration duration={duration || 0} /></span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
62
src/components/player/CompactPlayerTrackInfo.tsx
Normal file
62
src/components/player/CompactPlayerTrackInfo.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import type { PlayerState } from '@/lib/api/services/player'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Maximize2, Music } from 'lucide-react'
|
||||
import { memo } from 'react'
|
||||
|
||||
interface CompactPlayerTrackInfoProps {
|
||||
currentSound: PlayerState['current_sound']
|
||||
playlistName?: string
|
||||
onExpand: () => void
|
||||
}
|
||||
|
||||
export const CompactPlayerTrackInfo = memo(function CompactPlayerTrackInfo({
|
||||
currentSound,
|
||||
playlistName,
|
||||
onExpand,
|
||||
}: CompactPlayerTrackInfoProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-muted rounded flex items-center justify-center overflow-hidden">
|
||||
{currentSound?.thumbnail ? (
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(currentSound.id)}
|
||||
alt={currentSound.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={e => {
|
||||
// Hide image and show music icon if thumbnail fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const musicIcon = target.nextElementSibling as HTMLElement
|
||||
if (musicIcon) musicIcon.style.display = 'block'
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Music
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground',
|
||||
currentSound?.thumbnail ? 'hidden' : 'block',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{currentSound?.name || 'No track selected'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{playlistName}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onExpand}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
title="Expand Player"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
105
src/components/player/PlayNextQueue.tsx
Normal file
105
src/components/player/PlayNextQueue.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import { type PlayerSound } from '@/lib/api/services/player'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import { Music, ListPlus } from 'lucide-react'
|
||||
|
||||
interface PlayNextQueueProps {
|
||||
queue: PlayerSound[]
|
||||
}
|
||||
|
||||
export function PlayNextQueue({ queue }: PlayNextQueueProps) {
|
||||
if (queue.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate total duration
|
||||
const totalDuration = queue.reduce((sum, sound) => sum + sound.duration, 0)
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center justify-between py-2 px-2 rounded hover:bg-muted/50 cursor-pointer transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListPlus className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Play Next</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{queue.length} {queue.length === 1 ? 'track' : 'tracks'}
|
||||
</Badge>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="left" align="start" className="w-80 p-0">
|
||||
<div className="w-full flex flex-col">
|
||||
{/* Header - Fixed */}
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<h4 className="font-semibold text-sm">Play Next Queue</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{queue.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Track List - Scrollable */}
|
||||
<ScrollArea className="h-96">
|
||||
<div className="w-full space-y-1 p-3 pt-2">
|
||||
{queue.map((sound, index) => (
|
||||
<div
|
||||
key={`${sound.id}-${index}`}
|
||||
className="grid grid-cols-10 gap-2 items-center py-1.5 px-2 rounded hover:bg-muted/50 text-xs"
|
||||
>
|
||||
{/* Queue position - 1 column */}
|
||||
<div className="col-span-1 flex justify-center">
|
||||
<span className="text-muted-foreground">{index + 1}</span>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail - 1 column */}
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted rounded flex items-center justify-center overflow-hidden w-5 h-5">
|
||||
{sound.thumbnail ? (
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(sound.id)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Music className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Track name - 6 columns */}
|
||||
<div className="col-span-6">
|
||||
<span className="font-medium truncate block text-xs">
|
||||
{sound.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Duration - 2 columns */}
|
||||
<div className="col-span-2 text-right">
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{formatDuration(sound.duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer - Fixed */}
|
||||
<div className="p-3 pt-2 border-t bg-muted/30">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">Total playtime</span>
|
||||
<span className="font-medium">{formatDuration(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,5 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import {
|
||||
type MessageResponse,
|
||||
@@ -16,36 +7,74 @@ import {
|
||||
type PlayerState,
|
||||
playerService,
|
||||
} from '@/lib/api/services/player'
|
||||
import { soundsService } from '@/lib/api/services/sounds'
|
||||
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowRightToLine,
|
||||
Download,
|
||||
ExternalLink,
|
||||
List,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
MoreVertical,
|
||||
Music,
|
||||
Pause,
|
||||
Play,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
Shuffle,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Square,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Playlist } from './Playlist'
|
||||
import { PlayNextQueue } from './PlayNextQueue'
|
||||
import { PlayerControls } from './PlayerControls'
|
||||
import { PlayerProgress } from './PlayerProgress'
|
||||
import { PlayerTrackInfo } from './PlayerTrackInfo'
|
||||
|
||||
export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized' | 'sidebar'
|
||||
|
||||
// Helper function to deep compare player states to prevent unnecessary re-renders
|
||||
function isPlayerStateEqual(state1: PlayerState, state2: PlayerState): boolean {
|
||||
// Quick reference equality check first
|
||||
if (state1 === state2) return true
|
||||
|
||||
// Compare primitive properties
|
||||
if (
|
||||
state1.status !== state2.status ||
|
||||
state1.mode !== state2.mode ||
|
||||
state1.volume !== state2.volume ||
|
||||
state1.previous_volume !== state2.previous_volume ||
|
||||
state1.position !== state2.position ||
|
||||
state1.duration !== state2.duration ||
|
||||
state1.index !== state2.index
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare current_sound object
|
||||
if (state1.current_sound !== state2.current_sound) {
|
||||
if (!state1.current_sound || !state2.current_sound) return false
|
||||
if (
|
||||
state1.current_sound.id !== state2.current_sound.id ||
|
||||
state1.current_sound.name !== state2.current_sound.name ||
|
||||
state1.current_sound.thumbnail !== state2.current_sound.thumbnail ||
|
||||
state1.current_sound.extract_url !== state2.current_sound.extract_url
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Compare playlist object (only shallow comparison for performance)
|
||||
if (state1.playlist !== state2.playlist) {
|
||||
if (!state1.playlist || !state2.playlist) return false
|
||||
if (
|
||||
state1.playlist.id !== state2.playlist.id ||
|
||||
state1.playlist.sounds.length !== state2.playlist.sounds.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Compare play_next_queue length
|
||||
if (state1.play_next_queue.length !== state2.play_next_queue.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
interface PlayerProps {
|
||||
className?: string
|
||||
onPlayerModeChange?: (mode: PlayerDisplayMode) => void
|
||||
@@ -58,6 +87,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
volume: 80,
|
||||
previous_volume: 80,
|
||||
position: 0,
|
||||
play_next_queue: [],
|
||||
})
|
||||
const [displayMode, setDisplayMode] = useState<PlayerDisplayMode>(() => {
|
||||
// Initialize from localStorage or default to 'normal'
|
||||
@@ -84,6 +114,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
const [showPlaylist, setShowPlaylist] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
|
||||
// Load initial state
|
||||
useEffect(() => {
|
||||
const loadState = async () => {
|
||||
@@ -97,12 +128,19 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
loadState()
|
||||
}, [])
|
||||
|
||||
// Listen for player state updates
|
||||
// Listen for player state updates with optimization
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
|
||||
useEffect(() => {
|
||||
const handlePlayerState = (...args: unknown[]) => {
|
||||
const newState = args[0] as PlayerState
|
||||
|
||||
// Only update state if it actually changed to prevent unnecessary re-renders
|
||||
if (!isPlayerStateEqual(stateRef.current, newState)) {
|
||||
setState(newState)
|
||||
}
|
||||
}
|
||||
|
||||
playerEvents.on(PLAYER_EVENTS.PLAYER_STATE, handlePlayerState)
|
||||
|
||||
@@ -216,20 +254,16 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
}
|
||||
}, [state.current_sound])
|
||||
|
||||
const getModeIcon = () => {
|
||||
switch (state.mode) {
|
||||
case 'continuous':
|
||||
return <ArrowRight className="h-4 w-4" />
|
||||
case 'loop':
|
||||
return <Repeat className="h-4 w-4" />
|
||||
case 'loop_one':
|
||||
return <Repeat1 className="h-4 w-4" />
|
||||
case 'random':
|
||||
return <Shuffle className="h-4 w-4" />
|
||||
default:
|
||||
return <ArrowRightToLine className="h-4 w-4" />
|
||||
}
|
||||
const handleStopAllSounds = useCallback(async () => {
|
||||
try {
|
||||
await soundsService.stopSounds()
|
||||
toast.success('All sounds stopped')
|
||||
} catch (error) {
|
||||
console.error('Failed to stop all sounds:', error)
|
||||
toast.error('Failed to stop all sounds')
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
const expandFromSidebar = useCallback(() => {
|
||||
setDisplayMode('normal')
|
||||
@@ -252,46 +286,17 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
<Card className="w-48 bg-background/90 backdrop-blur-sm pt-0 pb-0">
|
||||
<CardContent className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handlePrevious}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{state.status === 'playing' ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleStop}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleNext}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</Button>
|
||||
<PlayerControls
|
||||
status={state.status}
|
||||
mode={state.mode}
|
||||
isLoading={isLoading}
|
||||
onPlayPause={handlePlayPause}
|
||||
onStop={handleStop}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onModeChange={handleModeChange}
|
||||
variant="minimized"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -330,180 +335,32 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Album Art / Thumbnail */}
|
||||
<div className="mb-4">
|
||||
{state.current_sound?.thumbnail ? (
|
||||
<div className="w-full aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
alt={state.current_sound.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={e => {
|
||||
// Hide image and show music icon if thumbnail fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const musicIcon = target.nextElementSibling as HTMLElement
|
||||
if (musicIcon) musicIcon.style.display = 'block'
|
||||
}}
|
||||
<PlayerTrackInfo
|
||||
currentSound={state.current_sound}
|
||||
onDownloadSound={handleDownloadSound}
|
||||
/>
|
||||
<Music
|
||||
className={cn(
|
||||
'h-8 w-8 text-muted-foreground',
|
||||
state.current_sound?.thumbnail ? 'hidden' : 'block',
|
||||
)}
|
||||
|
||||
<PlayerProgress
|
||||
position={state.position}
|
||||
duration={state.duration || 0}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="mb-4 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<h3 className="font-medium text-sm truncate">
|
||||
{state.current_sound?.name || 'No track selected'}
|
||||
</h3>
|
||||
{state.current_sound &&
|
||||
(state.current_sound.extract_url || state.current_sound.id) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{state.current_sound.extract_url && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={state.current_sound.extract_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Source
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={handleDownloadSound}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
File
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<Progress
|
||||
value={(state.position / (state.duration || 1)) * 100}
|
||||
className="w-full h-2 cursor-pointer"
|
||||
onClick={e => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const clickX = e.clientX - rect.left
|
||||
const percentage = clickX / rect.width
|
||||
const newPosition = Math.round(percentage * (state.duration || 0))
|
||||
handleSeek([newPosition])
|
||||
}}
|
||||
<PlayerControls
|
||||
status={state.status}
|
||||
mode={state.mode}
|
||||
isLoading={isLoading}
|
||||
showPlaylistButton={true}
|
||||
volume={state.volume}
|
||||
onPlayPause={handlePlayPause}
|
||||
onStop={handleStop}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onModeChange={handleModeChange}
|
||||
onTogglePlaylist={() => setShowPlaylist(!showPlaylist)}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
onMute={handleMute}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
||||
<span>{formatDuration(state.position)}</span>
|
||||
<span>{formatDuration(state.duration || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Controls */}
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleModeChange}
|
||||
className="h-8 w-8 p-0"
|
||||
title={`Mode: ${state.mode.replace('_', ' ')}`}
|
||||
>
|
||||
{getModeIcon()}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handlePrevious}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading}
|
||||
className="h-10 w-10 rounded-full"
|
||||
>
|
||||
{state.status === 'playing' ? (
|
||||
<Pause className="h-5 w-5" />
|
||||
) : (
|
||||
<Play className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleStop}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleNext}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowPlaylist(!showPlaylist)}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Toggle Playlist"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Secondary Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{state.mode.replace('_', ' ')}
|
||||
</Badge>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleMute}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{state.volume === 0 ? (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
) : (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="w-16">
|
||||
<Slider
|
||||
value={[state.volume]}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={handleVolumeChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playlist */}
|
||||
{showPlaylist && state.playlist && (
|
||||
@@ -518,6 +375,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Play Next Queue */}
|
||||
{state.play_next_queue.length > 0 && (
|
||||
<div className="mt-2 border-t">
|
||||
<PlayNextQueue queue={state.play_next_queue} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -529,6 +393,16 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Now Playing</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleStopAllSounds}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-950/50"
|
||||
>
|
||||
<Square className="h-4 w-4 mr-2 fill-current" />
|
||||
Stop All Sounds
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -538,179 +412,50 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
Exit Fullscreen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex">
|
||||
{/* Main Player Area */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8">
|
||||
{/* Large Album Art */}
|
||||
<div className="max-w-300 max-h-200 aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden mb-8">
|
||||
{state.current_sound?.thumbnail ? (
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
||||
alt={state.current_sound.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={e => {
|
||||
// Hide image and show music icon if thumbnail fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const musicIcon = target.nextElementSibling as HTMLElement
|
||||
if (musicIcon) musicIcon.style.display = 'block'
|
||||
}}
|
||||
<PlayerTrackInfo
|
||||
currentSound={state.current_sound}
|
||||
onDownloadSound={handleDownloadSound}
|
||||
variant="maximized"
|
||||
/>
|
||||
) : null}
|
||||
<Music
|
||||
className={cn(
|
||||
'h-32 w-32 text-muted-foreground',
|
||||
state.current_sound?.thumbnail ? 'hidden' : 'block',
|
||||
)}
|
||||
|
||||
<PlayerProgress
|
||||
position={state.position}
|
||||
duration={state.duration || 0}
|
||||
onSeek={handleSeek}
|
||||
variant="maximized"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-3 mb-2">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{state.current_sound?.name || 'No track selected'}
|
||||
</h1>
|
||||
{state.current_sound &&
|
||||
(state.current_sound.extract_url || state.current_sound.id) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{state.current_sound.extract_url && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={state.current_sound.extract_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Source
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={handleDownloadSound}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
File
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full max-w-md mb-8">
|
||||
<Progress
|
||||
value={(state.position / (state.duration || 1)) * 100}
|
||||
className="w-full h-3 cursor-pointer"
|
||||
onClick={e => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const clickX = e.clientX - rect.left
|
||||
const percentage = clickX / rect.width
|
||||
const newPosition = Math.round(
|
||||
percentage * (state.duration || 0),
|
||||
)
|
||||
handleSeek([newPosition])
|
||||
}}
|
||||
<PlayerControls
|
||||
status={state.status}
|
||||
mode={state.mode}
|
||||
isLoading={isLoading}
|
||||
volume={state.volume}
|
||||
onPlayPause={handlePlayPause}
|
||||
onStop={handleStop}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onModeChange={handleModeChange}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
onMute={handleMute}
|
||||
variant="maximized"
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-muted-foreground mt-2">
|
||||
<span>{formatDuration(state.position)}</span>
|
||||
<span>{formatDuration(state.duration || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large Controls */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
onClick={handlePrevious}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SkipBack className="h-6 w-6" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading}
|
||||
className="h-16 w-16 rounded-full"
|
||||
>
|
||||
{state.status === 'playing' ? (
|
||||
<Pause className="h-8 w-8" />
|
||||
) : (
|
||||
<Play className="h-8 w-8" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
onClick={handleStop}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Square className="h-6 w-6" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
onClick={handleNext}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SkipForward className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Secondary Controls */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={handleModeChange}>
|
||||
{getModeIcon()}
|
||||
</Button>
|
||||
<Badge variant="secondary">{state.mode.replace('_', ' ')}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={handleMute}>
|
||||
{state.volume === 0 ? (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
) : (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="w-24">
|
||||
<Slider
|
||||
value={[state.volume]}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={handleVolumeChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground w-8">
|
||||
{Math.round(state.volume)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playlist Sidebar */}
|
||||
{state.playlist && (
|
||||
<div className="w-96 border-l bg-muted/10 backdrop-blur-sm">
|
||||
<div className="p-4 border-b">
|
||||
<div className="w-96 border-l bg-muted/10 backdrop-blur-sm flex flex-col">
|
||||
<div className="p-4 border-b flex-shrink-0">
|
||||
<h3 className="font-semibold">Playlist</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{state.playlist.sounds.length} tracks
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="p-4 overflow-y-auto flex-1">
|
||||
<Playlist
|
||||
playlist={state.playlist}
|
||||
currentIndex={state.index}
|
||||
@@ -722,6 +467,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
}
|
||||
variant="maximized"
|
||||
/>
|
||||
|
||||
{/* Play Next Queue */}
|
||||
{state.play_next_queue.length > 0 && (
|
||||
<div className="mt-2 border-t">
|
||||
<PlayNextQueue queue={state.play_next_queue} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
302
src/components/player/PlayerControls.tsx
Normal file
302
src/components/player/PlayerControls.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import type { PlayerMode, PlayerState } from '@/lib/api/services/player'
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowRightToLine,
|
||||
List,
|
||||
Pause,
|
||||
Play,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
Shuffle,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Square,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import { memo, useMemo } from 'react'
|
||||
|
||||
interface PlayerControlsProps {
|
||||
status: PlayerState['status']
|
||||
mode: PlayerMode
|
||||
isLoading: boolean
|
||||
showPlaylistButton?: boolean
|
||||
volume?: number
|
||||
onPlayPause: () => void
|
||||
onStop: () => void
|
||||
onPrevious: () => void
|
||||
onNext: () => void
|
||||
onModeChange: () => void
|
||||
onTogglePlaylist?: () => void
|
||||
onVolumeChange?: (volume: number[]) => void
|
||||
onMute?: () => void
|
||||
variant?: 'normal' | 'maximized' | 'minimized'
|
||||
}
|
||||
|
||||
export const PlayerControls = memo(function PlayerControls({
|
||||
status,
|
||||
mode,
|
||||
isLoading,
|
||||
showPlaylistButton = false,
|
||||
volume,
|
||||
onPlayPause,
|
||||
onStop,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onModeChange,
|
||||
onTogglePlaylist,
|
||||
onVolumeChange,
|
||||
onMute,
|
||||
variant = 'normal',
|
||||
}: PlayerControlsProps) {
|
||||
const isMinimized = variant === 'minimized'
|
||||
const isMaximized = variant === 'maximized'
|
||||
|
||||
const modeIcon = useMemo(() => {
|
||||
switch (mode) {
|
||||
case 'continuous':
|
||||
return <ArrowRight className="h-4 w-4" />
|
||||
case 'loop':
|
||||
return <Repeat className="h-4 w-4" />
|
||||
case 'loop_one':
|
||||
return <Repeat1 className="h-4 w-4" />
|
||||
case 'random':
|
||||
return <Shuffle className="h-4 w-4" />
|
||||
default:
|
||||
return <ArrowRightToLine className="h-4 w-4" />
|
||||
}
|
||||
}, [mode])
|
||||
|
||||
const modeLabel = useMemo(() =>
|
||||
mode.replace('_', ' '),
|
||||
[mode]
|
||||
)
|
||||
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onPrevious}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onPlayPause}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{status === 'playing' ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onStop}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onNext}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMaximized) {
|
||||
return (
|
||||
<div>
|
||||
{/* Large Controls */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
onClick={onPrevious}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SkipBack className="h-6 w-6" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={onPlayPause}
|
||||
disabled={isLoading}
|
||||
className="h-16 w-16 rounded-full"
|
||||
>
|
||||
{status === 'playing' ? (
|
||||
<Pause className="h-8 w-8" />
|
||||
) : (
|
||||
<Play className="h-8 w-8" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
onClick={onStop}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Square className="h-6 w-6" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
onClick={onNext}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SkipForward className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Secondary Controls */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={onModeChange}>
|
||||
{modeIcon}
|
||||
</Button>
|
||||
<Badge variant="secondary">{modeLabel}</Badge>
|
||||
</div>
|
||||
|
||||
{volume !== undefined && onVolumeChange && onMute && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={onMute}>
|
||||
{volume === 0 ? (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
) : (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="w-24">
|
||||
<Slider
|
||||
value={[volume]}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={onVolumeChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground w-8">
|
||||
{Math.round(volume)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Normal variant
|
||||
return (
|
||||
<>
|
||||
{/* Main Controls */}
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onModeChange}
|
||||
className="h-8 w-8 p-0"
|
||||
title={`Mode: ${modeLabel}`}
|
||||
>
|
||||
{modeIcon}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onPrevious}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onPlayPause}
|
||||
disabled={isLoading}
|
||||
className="h-10 w-10 rounded-full"
|
||||
>
|
||||
{status === 'playing' ? (
|
||||
<Pause className="h-5 w-5" />
|
||||
) : (
|
||||
<Play className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onStop}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onNext}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</Button>
|
||||
{showPlaylistButton && onTogglePlaylist && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onTogglePlaylist}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Toggle Playlist"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Secondary Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{modeLabel}
|
||||
</Badge>
|
||||
|
||||
{volume !== undefined && onVolumeChange && onMute && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onMute}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{volume === 0 ? (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
) : (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="w-16">
|
||||
<Slider
|
||||
value={[volume]}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={onVolumeChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
53
src/components/player/PlayerProgress.tsx
Normal file
53
src/components/player/PlayerProgress.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { NumberFlowDuration } from '../ui/number-flow-duration'
|
||||
|
||||
interface PlayerProgressProps {
|
||||
position: number
|
||||
duration: number
|
||||
onSeek: (position: number[]) => void
|
||||
variant?: 'normal' | 'maximized'
|
||||
}
|
||||
|
||||
export const PlayerProgress = memo(function PlayerProgress({
|
||||
position,
|
||||
duration,
|
||||
onSeek,
|
||||
variant = 'normal',
|
||||
}: PlayerProgressProps) {
|
||||
const isMaximized = variant === 'maximized'
|
||||
|
||||
const progressPercentage = useMemo(() =>
|
||||
(position / (duration || 1)) * 100,
|
||||
[position, duration]
|
||||
)
|
||||
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const clickX = e.clientX - rect.left
|
||||
const percentage = clickX / rect.width
|
||||
const newPosition = Math.round(percentage * (duration || 0))
|
||||
onSeek([newPosition])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={isMaximized ? 'w-full max-w-md mb-8' : 'mb-4'}>
|
||||
<Progress
|
||||
value={progressPercentage}
|
||||
className={cn(
|
||||
'w-full cursor-pointer',
|
||||
isMaximized ? 'h-3' : 'h-2'
|
||||
)}
|
||||
onClick={handleProgressClick}
|
||||
/>
|
||||
<div className={cn(
|
||||
'flex justify-between text-muted-foreground mt-1',
|
||||
isMaximized ? 'text-sm mt-2' : 'text-xs'
|
||||
)}>
|
||||
<NumberFlowDuration duration={position} />
|
||||
<NumberFlowDuration duration={duration || 0} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
103
src/components/player/PlayerTrackInfo.tsx
Normal file
103
src/components/player/PlayerTrackInfo.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import type { PlayerState } from '@/lib/api/services/player'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Download, ExternalLink, MoreVertical, Music } from 'lucide-react'
|
||||
import { memo } from 'react'
|
||||
|
||||
interface PlayerTrackInfoProps {
|
||||
currentSound: PlayerState['current_sound']
|
||||
onDownloadSound: () => void
|
||||
variant?: 'normal' | 'maximized'
|
||||
}
|
||||
|
||||
export const PlayerTrackInfo = memo(function PlayerTrackInfo({
|
||||
currentSound,
|
||||
onDownloadSound,
|
||||
variant = 'normal',
|
||||
}: PlayerTrackInfoProps) {
|
||||
const isMaximized = variant === 'maximized'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Album Art / Thumbnail */}
|
||||
<div className={isMaximized ? 'max-w-300 max-h-200 aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden mb-8' : 'mb-4'}>
|
||||
{currentSound?.thumbnail ? (
|
||||
<div className={isMaximized ? 'w-full h-full' : 'w-full aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden'}>
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(currentSound.id)}
|
||||
alt={currentSound.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={e => {
|
||||
// Hide image and show music icon if thumbnail fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const musicIcon = target.nextElementSibling as HTMLElement
|
||||
if (musicIcon) musicIcon.style.display = 'block'
|
||||
}}
|
||||
/>
|
||||
<Music
|
||||
className={cn(
|
||||
isMaximized ? 'h-32 w-32 text-muted-foreground' : 'h-8 w-8 text-muted-foreground',
|
||||
currentSound?.thumbnail ? 'hidden' : 'block',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : isMaximized ? (
|
||||
<Music className="h-32 w-32 text-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className={cn('text-center', isMaximized ? 'mb-8' : 'mb-4')}>
|
||||
<div className={cn('flex items-center justify-center gap-2', isMaximized && 'gap-3 mb-2')}>
|
||||
<h3 className={cn('font-medium truncate', isMaximized ? 'text-2xl font-bold' : 'text-sm')}>
|
||||
{currentSound?.name || 'No track selected'}
|
||||
</h3>
|
||||
{currentSound &&
|
||||
(currentSound.extract_url || currentSound.id) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={isMaximized ? 'h-6 w-6 p-0' : 'h-4 w-4 p-0'}
|
||||
>
|
||||
<MoreVertical className={isMaximized ? 'h-4 w-4' : 'h-3 w-3'} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{currentSound.extract_url && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={currentSound.extract_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Source
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={onDownloadSound}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
File
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -1,10 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import { type PlayerPlaylist } from '@/lib/api/services/player'
|
||||
import { type PlayerPlaylist, playerService } from '@/lib/api/services/player'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import { Music, Play } from 'lucide-react'
|
||||
import { Music, Play, Search, X, ListPlus } from 'lucide-react'
|
||||
|
||||
interface PlaylistProps {
|
||||
playlist: PlayerPlaylist
|
||||
@@ -19,6 +28,20 @@ export function Playlist({
|
||||
onTrackSelect,
|
||||
variant = 'normal',
|
||||
}: PlaylistProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const filteredSounds = playlist.sounds.filter((sound) =>
|
||||
sound.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const handleAddToPlayNext = async (soundId: number) => {
|
||||
try {
|
||||
await playerService.addToPlayNext(soundId)
|
||||
} catch (error) {
|
||||
console.error('Failed to add track to play next:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Header */}
|
||||
@@ -29,26 +52,51 @@ export function Playlist({
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-2">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search tracks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-8 pr-8 text-xs"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 h-8 w-8"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track List */}
|
||||
<ScrollArea
|
||||
className={variant === 'maximized' ? 'h-[calc(100vh-230px)]' : 'h-60'}
|
||||
className={variant === 'maximized' ? 'h-[calc(100vh-320px)]' : 'h-60'}
|
||||
>
|
||||
<div className="w-full">
|
||||
{playlist.sounds.map((sound, index) => (
|
||||
{filteredSounds.map((sound) => {
|
||||
const originalIndex = playlist.sounds.findIndex((s) => s.id === sound.id)
|
||||
return (
|
||||
<ContextMenu key={sound.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
key={sound.id}
|
||||
className={cn(
|
||||
'grid grid-cols-10 gap-2 items-center py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer text-xs',
|
||||
currentIndex === index && 'bg-primary/10 text-primary',
|
||||
currentIndex === originalIndex && 'bg-primary/10 text-primary',
|
||||
)}
|
||||
onClick={() => onTrackSelect(index)}
|
||||
onClick={() => onTrackSelect(originalIndex)}
|
||||
>
|
||||
{/* Track number/play icon - 1 column */}
|
||||
<div className="col-span-1 flex justify-center">
|
||||
{currentIndex === index ? (
|
||||
{currentIndex === originalIndex ? (
|
||||
<Play className="h-3 w-3" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">{index + 1}</span>
|
||||
<span className="text-muted-foreground">{originalIndex + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -78,7 +126,7 @@ export function Playlist({
|
||||
className={cn(
|
||||
'font-medium truncate block',
|
||||
variant === 'maximized' ? 'text-sm' : 'text-xs',
|
||||
currentIndex === index ? 'text-primary' : 'text-foreground',
|
||||
currentIndex === originalIndex ? 'text-primary' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{sound.name}
|
||||
@@ -92,7 +140,15 @@ export function Playlist({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => handleAddToPlayNext(sound.id)}>
|
||||
<ListPlus className="mr-2 h-4 w-4" />
|
||||
Add to play next
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
|
||||
@@ -5,16 +5,17 @@ import type { Playlist } from '@/lib/api/services/playlists'
|
||||
import { formatDateDistanceToNow } from '@/utils/format-date'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Calendar, Clock, Edit, Heart, Music, Play, User } from 'lucide-react'
|
||||
import { Calendar, Clock, Edit, Heart, Music, Play, Trash2, User } from 'lucide-react'
|
||||
|
||||
interface PlaylistRowProps {
|
||||
playlist: Playlist
|
||||
onEdit: (playlist: Playlist) => void
|
||||
onSetCurrent: (playlist: Playlist) => void
|
||||
onFavoriteToggle?: (playlistId: number, isFavorited: boolean) => void
|
||||
onDelete?: (playlist: Playlist) => void
|
||||
}
|
||||
|
||||
export function PlaylistRow({ playlist, onEdit, onSetCurrent, onFavoriteToggle }: PlaylistRowProps) {
|
||||
export function PlaylistRow({ playlist, onEdit, onSetCurrent, onFavoriteToggle, onDelete }: PlaylistRowProps) {
|
||||
const handleFavoriteToggle = () => {
|
||||
if (onFavoriteToggle) {
|
||||
onFavoriteToggle(playlist.id, !playlist.is_favorited)
|
||||
@@ -121,6 +122,17 @@ export function PlaylistRow({ playlist, onEdit, onSetCurrent, onFavoriteToggle }
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && !playlist.is_main && playlist.is_deletable && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(playlist)}
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
title={`Delete "${playlist.name}"`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -13,9 +13,10 @@ interface PlaylistTableProps {
|
||||
onEdit: (playlist: Playlist) => void
|
||||
onSetCurrent: (playlist: Playlist) => void
|
||||
onFavoriteToggle?: (playlistId: number, isFavorited: boolean) => void
|
||||
onDelete?: (playlist: Playlist) => void
|
||||
}
|
||||
|
||||
export function PlaylistTable({ playlists, onEdit, onSetCurrent, onFavoriteToggle }: PlaylistTableProps) {
|
||||
export function PlaylistTable({ playlists, onEdit, onSetCurrent, onFavoriteToggle, onDelete }: PlaylistTableProps) {
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
@@ -39,6 +40,7 @@ export function PlaylistTable({ playlists, onEdit, onSetCurrent, onFavoriteToggl
|
||||
onEdit={onEdit}
|
||||
onSetCurrent={onSetCurrent}
|
||||
onFavoriteToggle={onFavoriteToggle}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -38,7 +38,7 @@ interface CreateTaskDialogProps {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const TASK_TYPES: TaskType[] = ['credit_recharge', 'play_sound', 'play_playlist']
|
||||
const TASK_TYPES: TaskType[] = [/*'credit_recharge',*/ 'play_sound', 'play_playlist']
|
||||
const RECURRENCE_TYPES: RecurrenceType[] = ['none', 'minutely', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'cron']
|
||||
|
||||
export function CreateTaskDialog({
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
getTaskTypeLabel,
|
||||
} from '@/lib/api/services/schedulers'
|
||||
import {
|
||||
CalendarPlus,
|
||||
Filter,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
@@ -34,7 +35,7 @@ interface SchedulersHeaderProps {
|
||||
}
|
||||
|
||||
const TASK_STATUSES: TaskStatus[] = ['pending', 'running', 'completed', 'failed', 'cancelled']
|
||||
const TASK_TYPES: TaskType[] = ['credit_recharge', 'play_sound', 'play_playlist']
|
||||
const TASK_TYPES: TaskType[] = [/*'credit_recharge',*/ 'play_sound', 'play_playlist']
|
||||
|
||||
export function SchedulersHeader({
|
||||
searchQuery,
|
||||
@@ -64,18 +65,9 @@ export function SchedulersHeader({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={onCreateClick} size="sm">
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
Create Task
|
||||
<Button onClick={onCreateClick}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Extraction
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +88,8 @@ export function SchedulersHeader({
|
||||
value={statusFilter}
|
||||
onValueChange={onStatusFilterChange}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="All Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -113,7 +106,8 @@ export function SchedulersHeader({
|
||||
value={taskTypeFilter}
|
||||
onValueChange={onTaskTypeFilterChange}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="All Types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -125,6 +119,18 @@ export function SchedulersHeader({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title="Refresh extractions"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { formatDate } from '@/utils/format-date'
|
||||
@@ -20,8 +19,6 @@ import {
|
||||
import {
|
||||
CalendarClock,
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
Square,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
@@ -29,36 +26,26 @@ import { toast } from 'sonner'
|
||||
|
||||
interface SchedulersTableProps {
|
||||
tasks: ScheduledTask[]
|
||||
onTaskUpdated?: (task: ScheduledTask) => void
|
||||
onTaskDeleted?: (taskId: number) => void
|
||||
}
|
||||
|
||||
export function SchedulersTable({ tasks, onTaskUpdated, onTaskDeleted }: SchedulersTableProps) {
|
||||
export function SchedulersTable({ tasks, onTaskDeleted }: SchedulersTableProps) {
|
||||
const [loadingActions, setLoadingActions] = useState<Record<number, boolean>>({})
|
||||
|
||||
const handleToggleActive = async (task: ScheduledTask) => {
|
||||
if (loadingActions[task.id]) return
|
||||
|
||||
try {
|
||||
setLoadingActions(prev => ({ ...prev, [task.id]: true }))
|
||||
|
||||
const updatedTask = await schedulersService.updateTask(task.id, {
|
||||
is_active: !task.is_active,
|
||||
})
|
||||
|
||||
onTaskUpdated?.(updatedTask)
|
||||
toast.success(`Task ${task.is_active ? 'paused' : 'resumed'} successfully`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update task'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoadingActions(prev => ({ ...prev, [task.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelTask = async (task: ScheduledTask) => {
|
||||
if (loadingActions[task.id]) return
|
||||
|
||||
// Confirm deletion
|
||||
const confirmMessage = `Are you sure you want to delete the task "${task.name}"?${
|
||||
task.status === 'pending' || task.status === 'running'
|
||||
? '\n\nThis task is currently active and will be stopped immediately.'
|
||||
: ''
|
||||
}\n\nThis action cannot be undone.`
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingActions(prev => ({ ...prev, [task.id]: true }))
|
||||
|
||||
@@ -85,7 +72,7 @@ export function SchedulersTable({ tasks, onTaskUpdated, onTaskDeleted }: Schedul
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{tasks.map((task) => (
|
||||
<Card key={task.id} className={task.is_active ? '' : 'opacity-60'}>
|
||||
<Card key={task.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
@@ -94,11 +81,6 @@ export function SchedulersTable({ tasks, onTaskUpdated, onTaskDeleted }: Schedul
|
||||
<Badge variant={getTaskStatusVariant(task.status)}>
|
||||
{getTaskStatusLabel(task.status)}
|
||||
</Badge>
|
||||
{!task.is_active && (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
Paused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getTaskTypeLabel(task.task_type)}
|
||||
@@ -121,26 +103,8 @@ export function SchedulersTable({ tasks, onTaskUpdated, onTaskDeleted }: Schedul
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleToggleActive(task)}
|
||||
disabled={task.status === 'completed' || task.status === 'cancelled'}
|
||||
>
|
||||
{task.is_active ? (
|
||||
<>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Pause Task
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Resume Task
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleCancelTask(task)}
|
||||
disabled={task.status === 'completed' || task.status === 'cancelled'}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { type Sound } from '@/lib/api/services/sounds'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
@@ -27,6 +28,19 @@ export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }:
|
||||
onFavoriteToggle(sound.id, !sound.is_favorited)
|
||||
}
|
||||
|
||||
const getBadgeVariant = (type: Sound['type']) => {
|
||||
switch (type) {
|
||||
case 'SDB':
|
||||
return 'default'
|
||||
case 'TTS':
|
||||
return 'secondary'
|
||||
case 'EXT':
|
||||
return 'outline'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={handlePlaySound}
|
||||
@@ -36,6 +50,14 @@ export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }:
|
||||
)}
|
||||
>
|
||||
<CardContent className="grid grid-cols-1 pl-3 pr-3 gap-1">
|
||||
{/* Type badge */}
|
||||
<Badge
|
||||
variant={getBadgeVariant(sound.type)}
|
||||
className="absolute top-2 left-2 text-xs px-1 py-0 h-4 text-[10px]"
|
||||
>
|
||||
{sound.type}
|
||||
</Badge>
|
||||
|
||||
{/* Favorite button */}
|
||||
<button
|
||||
data-favorite-button
|
||||
@@ -57,7 +79,7 @@ export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }:
|
||||
/>
|
||||
</button>
|
||||
|
||||
<h3 className="font-medium text-s truncate pr-8">{sound.name}</h3>
|
||||
<h3 className="font-medium text-s truncate pl-8 pr-6">{sound.name}</h3>
|
||||
<div className="grid grid-cols-2 gap-1 text-xs text-muted-foreground">
|
||||
<div className="flex">
|
||||
<Clock className="h-3.5 w-3.5 mr-0.5" />
|
||||
|
||||
279
src/components/tts/CreateTTSDialog.tsx
Normal file
279
src/components/tts/CreateTTSDialog.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2, Mic } from 'lucide-react'
|
||||
import { ttsService, type TTSProvider } from '@/lib/api/services/tts'
|
||||
import { TTS_EVENTS, ttsEvents } from '@/lib/events'
|
||||
import { toast } from 'sonner'
|
||||
import { getSortedLanguages, getLanguageDisplayName } from '@/lib/constants/gtts-languages'
|
||||
|
||||
interface FormData {
|
||||
text: string
|
||||
provider: string
|
||||
language?: string
|
||||
slow?: boolean
|
||||
}
|
||||
|
||||
interface CreateTTSDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function CreateTTSDialog({ open, onOpenChange }: CreateTTSDialogProps) {
|
||||
const [selectedProvider, setSelectedProvider] = useState<TTSProvider | null>(null)
|
||||
const [providers, setProviders] = useState<Record<string, TTSProvider> | null>(null)
|
||||
const [isLoadingProviders, setIsLoadingProviders] = useState(false)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
text: '',
|
||||
provider: 'gtts',
|
||||
language: 'en',
|
||||
slow: false,
|
||||
})
|
||||
|
||||
// Prepare language options for combobox
|
||||
const languageOptions: ComboboxOption[] = getSortedLanguages().map((lang) => ({
|
||||
value: lang.code,
|
||||
label: getLanguageDisplayName(lang),
|
||||
searchValue: `${lang.name} ${lang.region || ''} ${lang.code}`.toLowerCase()
|
||||
}))
|
||||
|
||||
// Load providers when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && !providers) {
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
setIsLoadingProviders(true)
|
||||
const data = await ttsService.getProviders()
|
||||
setProviders(data)
|
||||
} catch (error) {
|
||||
toast.error('Failed to load TTS providers')
|
||||
} finally {
|
||||
setIsLoadingProviders(false)
|
||||
}
|
||||
}
|
||||
loadProviders()
|
||||
}
|
||||
}, [open, providers])
|
||||
|
||||
const generateTTS = async (data: FormData) => {
|
||||
try {
|
||||
setIsGenerating(true)
|
||||
const { text, provider, language, slow } = data
|
||||
const response = await ttsService.generateTTS({
|
||||
text,
|
||||
provider,
|
||||
options: {
|
||||
...(language && { lang: language }),
|
||||
tld: 'com', // Always use .com TLD
|
||||
...(slow !== undefined && { slow }),
|
||||
},
|
||||
})
|
||||
|
||||
toast.success(response.message)
|
||||
onOpenChange(false)
|
||||
handleReset()
|
||||
|
||||
// Emit event for new TTS created
|
||||
ttsEvents.emit(TTS_EVENTS.TTS_CREATED, response.tts)
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to generate TTS')
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Update selected provider when form provider changes
|
||||
useEffect(() => {
|
||||
if (providers && formData.provider) {
|
||||
setSelectedProvider(providers[formData.provider] || null)
|
||||
}
|
||||
}, [formData.provider, providers])
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
if (!formData.text.trim()) {
|
||||
errors.text = 'Text is required'
|
||||
} else if (formData.text.length > 1000) {
|
||||
errors.text = 'Text must be less than 1000 characters'
|
||||
}
|
||||
|
||||
if (!formData.provider) {
|
||||
errors.provider = 'Provider is required'
|
||||
}
|
||||
|
||||
setFormErrors(errors)
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (validateForm()) {
|
||||
generateTTS(formData)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
text: '',
|
||||
provider: 'gtts',
|
||||
language: 'en',
|
||||
slow: false,
|
||||
})
|
||||
setFormErrors({})
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isGenerating) {
|
||||
handleReset()
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Mic className="h-5 w-5" />
|
||||
Generate Text to Speech
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Convert text to speech using various TTS providers. The audio will be processed and added to your soundboard.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text">Text</Label>
|
||||
<Textarea
|
||||
id="text"
|
||||
placeholder="Enter the text you want to convert to speech..."
|
||||
className="min-h-[100px] resize-none"
|
||||
value={formData.text}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, text: e.target.value }))}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Maximum 1000 characters ({formData.text?.length || 0}/1000)
|
||||
</p>
|
||||
{formErrors.text && (
|
||||
<p className="text-sm text-destructive">{formErrors.text}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider">Provider</Label>
|
||||
<Select
|
||||
value={formData.provider}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, provider: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a TTS provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingProviders ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
Loading providers...
|
||||
</SelectItem>
|
||||
) : (
|
||||
providers &&
|
||||
Object.entries(providers).map(([key, provider]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{provider.name.toUpperCase()}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{provider.file_extension}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formErrors.provider && (
|
||||
<p className="text-sm text-destructive">{formErrors.provider}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedProvider && (
|
||||
<div className="space-y-4 p-4 border rounded-lg bg-muted/50">
|
||||
<Label className="text-sm font-medium">Provider Options</Label>
|
||||
|
||||
{selectedProvider.name === 'gtts' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="language">Language</Label>
|
||||
<Combobox
|
||||
value={formData.language}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, language: value }))}
|
||||
options={languageOptions}
|
||||
placeholder="Select language..."
|
||||
searchPlaceholder="Search languages..."
|
||||
emptyMessage="No language found."
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose from {languageOptions.length} supported languages including regional variants
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base">Slow Speech</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate speech at a slower pace
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.slow}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, slow: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isGenerating}>
|
||||
{isGenerating && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Generate TTS
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
131
src/components/tts/TTSHeader.tsx
Normal file
131
src/components/tts/TTSHeader.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { TTSSortField, TTSSortOrder } from '@/lib/api/services/tts'
|
||||
import { Plus, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react'
|
||||
|
||||
interface TTSHeaderProps {
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
sortBy: TTSSortField
|
||||
onSortByChange: (sortBy: TTSSortField) => void
|
||||
sortOrder: TTSSortOrder
|
||||
onSortOrderChange: (order: TTSSortOrder) => void
|
||||
onRefresh: () => void
|
||||
onCreateClick: () => void
|
||||
loading: boolean
|
||||
error: string | null
|
||||
ttsCount: number
|
||||
}
|
||||
|
||||
export function TTSHeader({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
sortBy,
|
||||
onSortByChange,
|
||||
sortOrder,
|
||||
onSortOrderChange,
|
||||
onRefresh,
|
||||
onCreateClick,
|
||||
loading,
|
||||
error,
|
||||
ttsCount,
|
||||
}: TTSHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Text to Speech</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Generate speech from text using various TTS providers
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{!loading && !error && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{ttsCount} generation{ttsCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={onCreateClick}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Generate TTS
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Sort Controls */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by text or provider..."
|
||||
value={searchQuery}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSearchChange('')}
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0 hover:bg-muted"
|
||||
title="Clear search"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={value => onSortByChange(value as TTSSortField)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="created_at">Created Date</SelectItem>
|
||||
<SelectItem value="text">Text</SelectItem>
|
||||
<SelectItem value="provider">Provider</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
title={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'}
|
||||
>
|
||||
{sortOrder === 'asc' ? (
|
||||
<SortAsc className="h-4 w-4" />
|
||||
) : (
|
||||
<SortDesc className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title="Refresh TTS history"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
239
src/components/tts/TTSList.tsx
Normal file
239
src/components/tts/TTSList.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
import { ttsService, type TTSResponse } from '@/lib/api/services/tts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TTSRow } from './TTSRow'
|
||||
import { RefreshCw, Search } from 'lucide-react'
|
||||
|
||||
export function TTSList() {
|
||||
const [ttsHistory, setTTSHistory] = useState<TTSResponse[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState('created_at')
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
|
||||
const [limit, setLimit] = useState(50)
|
||||
|
||||
const fetchTTSHistory = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
const data = await ttsService.getTTSHistory({ limit })
|
||||
setTTSHistory(data.tts)
|
||||
} catch (err) {
|
||||
setError('Failed to load TTS history')
|
||||
console.error('Failed to fetch TTS history:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [limit])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTTSHistory()
|
||||
}, [fetchTTSHistory])
|
||||
|
||||
// Listen for TTS generation events to refresh the list
|
||||
useEffect(() => {
|
||||
const handleTTSGenerated = () => {
|
||||
fetchTTSHistory()
|
||||
}
|
||||
|
||||
window.addEventListener('tts-generated', handleTTSGenerated)
|
||||
return () => {
|
||||
window.removeEventListener('tts-generated', handleTTSGenerated)
|
||||
}
|
||||
}, [fetchTTSHistory])
|
||||
|
||||
const filteredHistory = ttsHistory.filter((tts) =>
|
||||
tts.text.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tts.provider.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const sortedHistory = [...filteredHistory].sort((a, b) => {
|
||||
let aValue: any = a[sortBy as keyof TTSResponse]
|
||||
let bValue: any = b[sortBy as keyof TTSResponse]
|
||||
|
||||
// Convert dates to timestamps for comparison
|
||||
if (sortBy === 'created_at') {
|
||||
aValue = new Date(aValue).getTime()
|
||||
bValue = new Date(bValue).getTime()
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue > bValue ? 1 : -1
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
Failed to load TTS history. Please try again.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search and filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
TTS History
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchTTSHistory}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="search">Search</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Search by text or provider..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sortBy">Sort by</Label>
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger id="sortBy" className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="created_at">Created</SelectItem>
|
||||
<SelectItem value="text">Text</SelectItem>
|
||||
<SelectItem value="provider">Provider</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sortOrder">Order</Label>
|
||||
<Select
|
||||
value={sortOrder}
|
||||
onValueChange={(value: 'asc' | 'desc') => setSortOrder(value)}
|
||||
>
|
||||
<SelectTrigger id="sortOrder" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="desc">Newest</SelectItem>
|
||||
<SelectItem value="asc">Oldest</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="limit">Limit</Label>
|
||||
<Select
|
||||
value={limit.toString()}
|
||||
onValueChange={(value) => setLimit(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger id="limit" className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="25">25</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results */}
|
||||
<div className="space-y-2">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
{/* Badges row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-6 w-16" />
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</div>
|
||||
|
||||
{/* Text content */}
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<div className="flex gap-1">
|
||||
<Skeleton className="h-5 w-12" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date and metadata */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Play button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : sortedHistory.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
{searchQuery
|
||||
? 'No TTS generations match your search.'
|
||||
: 'No TTS generations yet. Create your first one!'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{sortedHistory.length} generation{sortedHistory.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sortedHistory.map((tts) => (
|
||||
<TTSRow key={tts.id} tts={tts} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
src/components/tts/TTSLoadingStates.tsx
Normal file
112
src/components/tts/TTSLoadingStates.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { AlertCircle, Mic, RefreshCw } from 'lucide-react'
|
||||
|
||||
export function TTSLoading() {
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Text</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>Options</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[120px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="max-w-md">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Skeleton className="h-5 w-12" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TTSErrorProps {
|
||||
error: string
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function TTSError({ error, onRetry }: TTSErrorProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive" />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Failed to load TTS generations</h3>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
<Button onClick={onRetry} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface TTSEmptyProps {
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
export function TTSEmpty({ searchQuery }: TTSEmptyProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<Mic className="h-12 w-12 text-muted-foreground" />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{searchQuery ? 'No TTS generations found' : 'No TTS generations yet'}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchQuery
|
||||
? 'Try adjusting your search or create a new TTS generation.'
|
||||
: 'Create your first text-to-speech generation to get started.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
121
src/components/tts/TTSRow.tsx
Normal file
121
src/components/tts/TTSRow.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { format } from 'date-fns'
|
||||
import { Clock, Mic, Volume2, CheckCircle, Loader } from 'lucide-react'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { type TTSResponse } from '@/lib/api/services/tts'
|
||||
import { soundsService } from '@/lib/api/services/sounds'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface TTSRowProps {
|
||||
tts: TTSResponse
|
||||
}
|
||||
|
||||
export function TTSRow({ tts }: TTSRowProps) {
|
||||
const isCompleted = tts.sound_id !== null
|
||||
const isProcessing = tts.sound_id === null
|
||||
|
||||
const handlePlaySound = async () => {
|
||||
if (!tts.sound_id) {
|
||||
toast.error('This TTS is still being processed.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await soundsService.playSound(tts.sound_id)
|
||||
} catch (error) {
|
||||
toast.error('Failed to play the sound.')
|
||||
}
|
||||
}
|
||||
|
||||
const getProviderColor = (provider: string) => {
|
||||
const colors = {
|
||||
gtts: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||
}
|
||||
return colors[provider as keyof typeof colors] || 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
|
||||
}
|
||||
|
||||
const getStatusBadge = () => {
|
||||
if (isCompleted) {
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Complete
|
||||
</Badge>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Loader className="h-3 w-3 animate-spin" />
|
||||
Processing
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="transition-colors hover:bg-muted/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={getProviderColor(tts.provider)}>
|
||||
<Mic className="mr-1 h-3 w-3" />
|
||||
{tts.provider.toUpperCase()}
|
||||
</Badge>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-relaxed">
|
||||
"{tts.text}"
|
||||
</p>
|
||||
|
||||
{Object.keys(tts.options).length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Object.entries(tts.options).map(([key, value]) => (
|
||||
<Badge key={key} variant="outline" className="text-xs">
|
||||
{key}: {String(value)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{format(new Date(tts.created_at), 'MMM dd, yyyy HH:mm')}
|
||||
</div>
|
||||
{tts.sound_id && (
|
||||
<div className="flex items-center gap-1">
|
||||
Sound ID: {tts.sound_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handlePlaySound}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Volume2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isProcessing ? 'Sound is being processed' : 'Play sound'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
193
src/components/tts/TTSTable.tsx
Normal file
193
src/components/tts/TTSTable.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Calendar, CheckCircle, Clock, Loader, Mic, Trash2, Volume2, XCircle } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { type TTSResponse, ttsService } from '@/lib/api/services/tts'
|
||||
import { soundsService } from '@/lib/api/services/sounds'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDateDistanceToNow } from '@/utils/format-date'
|
||||
|
||||
interface TTSTableProps {
|
||||
ttsHistory: TTSResponse[]
|
||||
onTTSDeleted?: (ttsId: number) => void
|
||||
}
|
||||
|
||||
export function TTSTable({ ttsHistory, onTTSDeleted }: TTSTableProps) {
|
||||
const handlePlaySound = async (tts: TTSResponse) => {
|
||||
if (!tts.sound_id) {
|
||||
toast.error('This TTS is still being processed.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await soundsService.playSound(tts.sound_id)
|
||||
} catch (error) {
|
||||
toast.error('Failed to play the sound.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTTS = async (tts: TTSResponse) => {
|
||||
if (!confirm(`Are you sure you want to delete this TTS generation?\n\n"${tts.text}"\n\nThis will also delete the associated sound file and cannot be undone.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ttsService.deleteTTS(tts.id)
|
||||
toast.success('TTS generation deleted successfully')
|
||||
onTTSDeleted?.(tts.id)
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete TTS generation')
|
||||
}
|
||||
}
|
||||
|
||||
const getProviderColor = (provider: string) => {
|
||||
const colors = {
|
||||
gtts: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||
}
|
||||
return colors[provider as keyof typeof colors] || 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
|
||||
}
|
||||
|
||||
const getStatusBadge = (tts: TTSResponse) => {
|
||||
switch (tts.status) {
|
||||
case 'completed':
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Completed
|
||||
</Badge>
|
||||
)
|
||||
case 'processing':
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Loader className="h-3 w-3 animate-spin" />
|
||||
Processing
|
||||
</Badge>
|
||||
)
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Failed
|
||||
</Badge>
|
||||
)
|
||||
case 'pending':
|
||||
default:
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Pending
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Text</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>Options</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[120px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ttsHistory.map((tts) => (
|
||||
<TableRow key={tts.id}>
|
||||
<TableCell className="max-w-md">
|
||||
<div className="truncate font-medium">
|
||||
"{tts.text}"
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getProviderColor(tts.provider)}>
|
||||
<Mic className="mr-1 h-3 w-3" />
|
||||
{tts.provider.toUpperCase()}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{Object.keys(tts.options).length > 0 ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Object.entries(tts.options).map(([key, value]) => (
|
||||
<Badge key={key} variant="outline" className="text-xs">
|
||||
{key}: {String(value)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">None</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
{getStatusBadge(tts)}
|
||||
{tts.error && (
|
||||
<div
|
||||
className="text-xs text-destructive max-w-48 truncate"
|
||||
title={tts.error}
|
||||
>
|
||||
{tts.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDateDistanceToNow(tts.created_at)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePlaySound(tts)}
|
||||
disabled={!tts.sound_id}
|
||||
>
|
||||
<Volume2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{!tts.sound_id ? 'Sound is being processed' : 'Play sound'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteTTS(tts)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Delete TTS generation
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,18 +5,17 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
@@ -26,6 +25,8 @@ const buttonVariants = cva(
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
|
||||
@@ -20,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -179,7 +179,9 @@ function ChartTooltipContent({
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
@@ -275,7 +277,9 @@ function ChartLegendContent({
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
@@ -66,7 +64,7 @@ function ContextMenuSubTrigger({
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
@@ -209,7 +209,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
@@ -65,6 +66,7 @@ function SelectContent({
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
|
||||
@@ -53,7 +53,7 @@ function Slider({
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
@@ -44,13 +46,13 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
"bg-foreground text-background 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
EXTRACTION_EVENTS,
|
||||
PLAYER_EVENTS,
|
||||
SOUND_EVENTS,
|
||||
TTS_EVENTS,
|
||||
USER_EVENTS,
|
||||
authEvents,
|
||||
extractionEvents,
|
||||
playerEvents,
|
||||
soundEvents,
|
||||
ttsEvents,
|
||||
userEvents,
|
||||
} from '../lib/events'
|
||||
import { extractionsService } from '../lib/api/services/extractions'
|
||||
@@ -158,6 +160,28 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for TTS status updates
|
||||
newSocket.on('tts_completed', data => {
|
||||
// Emit local event for other components to listen to
|
||||
ttsEvents.emit(TTS_EVENTS.TTS_COMPLETED, data)
|
||||
|
||||
toast.success('TTS generation completed', {
|
||||
duration: 3000,
|
||||
})
|
||||
})
|
||||
|
||||
newSocket.on('tts_failed', data => {
|
||||
const { error } = data
|
||||
|
||||
// Emit local event for other components to listen to
|
||||
ttsEvents.emit(TTS_EVENTS.TTS_FAILED, data)
|
||||
|
||||
toast.error('TTS generation failed', {
|
||||
description: error,
|
||||
duration: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
return newSocket
|
||||
}, [user, fetchAndShowOngoingExtractions])
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
@@ -10,9 +10,9 @@ export function useIsMobile() {
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener('change', onChange)
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
|
||||
88
src/hooks/usePlayerState.ts
Normal file
88
src/hooks/usePlayerState.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
|
||||
import type { PlayerState } from '@/lib/api/services/player'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// Type for selecting specific parts of the player state
|
||||
type PlayerStateSelector<T> = (state: PlayerState) => T
|
||||
|
||||
// Custom hook for subscribing to specific parts of player state
|
||||
export function usePlayerState<T>(
|
||||
selector: PlayerStateSelector<T>,
|
||||
equalityFn?: (a: T, b: T) => boolean
|
||||
): T | null {
|
||||
const [selectedState, setSelectedState] = useState<T | null>(null)
|
||||
const selectorRef = useRef(selector)
|
||||
const equalityRef = useRef(equalityFn)
|
||||
|
||||
// Keep refs updated
|
||||
selectorRef.current = selector
|
||||
equalityRef.current = equalityFn
|
||||
|
||||
useEffect(() => {
|
||||
const handlePlayerState = (...args: unknown[]) => {
|
||||
const newState = args[0] as PlayerState
|
||||
const newSelectedState = selectorRef.current(newState)
|
||||
|
||||
setSelectedState(prevSelected => {
|
||||
// Use custom equality function if provided, otherwise use shallow comparison
|
||||
if (equalityRef.current) {
|
||||
if (prevSelected !== null && equalityRef.current(prevSelected, newSelectedState)) {
|
||||
return prevSelected
|
||||
}
|
||||
} else if (prevSelected === newSelectedState) {
|
||||
return prevSelected
|
||||
}
|
||||
|
||||
return newSelectedState
|
||||
})
|
||||
}
|
||||
|
||||
playerEvents.on(PLAYER_EVENTS.PLAYER_STATE, handlePlayerState)
|
||||
|
||||
return () => {
|
||||
playerEvents.off(PLAYER_EVENTS.PLAYER_STATE, handlePlayerState)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return selectedState
|
||||
}
|
||||
|
||||
// Specific hooks for common state selections
|
||||
export function usePlayerProgress() {
|
||||
return usePlayerState(
|
||||
useCallback((state: PlayerState) => ({
|
||||
position: state.position,
|
||||
duration: state.duration || 0,
|
||||
}), [])
|
||||
)
|
||||
}
|
||||
|
||||
export function usePlayerTrackInfo() {
|
||||
return usePlayerState(
|
||||
useCallback((state: PlayerState) => state.current_sound, [])
|
||||
)
|
||||
}
|
||||
|
||||
export function usePlayerControls() {
|
||||
return usePlayerState(
|
||||
useCallback((state: PlayerState) => ({
|
||||
status: state.status,
|
||||
mode: state.mode,
|
||||
}), [])
|
||||
)
|
||||
}
|
||||
|
||||
export function usePlayerVolume() {
|
||||
return usePlayerState(
|
||||
useCallback((state: PlayerState) => state.volume, [])
|
||||
)
|
||||
}
|
||||
|
||||
export function usePlayerPlaylist() {
|
||||
return usePlayerState(
|
||||
useCallback((state: PlayerState) => ({
|
||||
playlist: state.playlist,
|
||||
index: state.index,
|
||||
}), [])
|
||||
)
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export interface PlayerState {
|
||||
index?: number
|
||||
current_sound?: PlayerSound
|
||||
playlist?: PlayerPlaylist
|
||||
play_next_queue: PlayerSound[]
|
||||
}
|
||||
|
||||
export interface PlayerSeekRequest {
|
||||
@@ -147,6 +148,13 @@ export class PlayerService {
|
||||
async getState(): Promise<PlayerState> {
|
||||
return apiClient.get<PlayerState>('/api/v1/player/state')
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a sound to the play next queue
|
||||
*/
|
||||
async addToPlayNext(soundId: number): Promise<MessageResponse> {
|
||||
return apiClient.post<MessageResponse>(`/api/v1/player/play-next/${soundId}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const playerService = new PlayerService()
|
||||
|
||||
136
src/lib/api/services/tts.ts
Normal file
136
src/lib/api/services/tts.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface TTSRequest {
|
||||
text: string
|
||||
provider?: string
|
||||
options?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface TTSResponse {
|
||||
id: number
|
||||
text: string
|
||||
provider: string
|
||||
options: Record<string, any>
|
||||
status: string
|
||||
error: string | null
|
||||
sound_id: number | null
|
||||
user_id: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TTSGenerateResponse {
|
||||
message: string
|
||||
tts: TTSResponse
|
||||
}
|
||||
|
||||
export interface TTSProvider {
|
||||
name: string
|
||||
file_extension: string
|
||||
supported_languages: string[]
|
||||
option_schema: Record<string, any>
|
||||
}
|
||||
|
||||
export interface TTSProvidersResponse {
|
||||
[key: string]: TTSProvider
|
||||
}
|
||||
|
||||
export type TTSSortField = 'created_at' | 'text' | 'provider'
|
||||
export type TTSSortOrder = 'asc' | 'desc'
|
||||
|
||||
export interface GetTTSHistoryParams {
|
||||
search?: string
|
||||
sort_by?: TTSSortField
|
||||
sort_order?: TTSSortOrder
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface GetTTSHistoryResponse {
|
||||
tts: TTSResponse[]
|
||||
total: number
|
||||
total_pages: number
|
||||
current_page: number
|
||||
}
|
||||
|
||||
export const ttsService = {
|
||||
async generateTTS(request: TTSRequest): Promise<TTSGenerateResponse> {
|
||||
return await apiClient.post('/api/v1/tts', request)
|
||||
},
|
||||
|
||||
async getTTSHistory(params?: GetTTSHistoryParams): Promise<GetTTSHistoryResponse> {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
// Backend currently only supports limit and offset, not page-based pagination
|
||||
if (params?.limit) {
|
||||
searchParams.append('limit', params.limit.toString())
|
||||
}
|
||||
if (params?.page && params?.limit) {
|
||||
// Convert page to offset
|
||||
const offset = (params.page - 1) * params.limit
|
||||
searchParams.append('offset', offset.toString())
|
||||
}
|
||||
|
||||
const url = searchParams.toString()
|
||||
? `/api/v1/tts?${searchParams.toString()}`
|
||||
: '/api/v1/tts'
|
||||
|
||||
const ttsArray: TTSResponse[] = await apiClient.get(url)
|
||||
|
||||
// Apply client-side filtering and sorting since backend doesn't support them yet
|
||||
let filteredTTS = ttsArray
|
||||
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase()
|
||||
filteredTTS = filteredTTS.filter(tts =>
|
||||
tts.text.toLowerCase().includes(search) ||
|
||||
tts.provider.toLowerCase().includes(search)
|
||||
)
|
||||
}
|
||||
|
||||
if (params?.sort_by && params?.sort_order) {
|
||||
filteredTTS.sort((a, b) => {
|
||||
let aValue = a[params.sort_by as keyof TTSResponse]
|
||||
let bValue = b[params.sort_by as keyof TTSResponse]
|
||||
|
||||
// Convert dates to timestamps for comparison
|
||||
if (params.sort_by === 'created_at') {
|
||||
aValue = new Date(aValue as string).getTime()
|
||||
bValue = new Date(bValue as string).getTime()
|
||||
}
|
||||
|
||||
// Handle null values
|
||||
if (aValue === null && bValue === null) return 0
|
||||
if (aValue === null) return 1
|
||||
if (bValue === null) return -1
|
||||
|
||||
const comparison = aValue > bValue ? 1 : -1
|
||||
return params.sort_order === 'asc' ? comparison : -comparison
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate pagination info
|
||||
const limit = params?.limit || 50
|
||||
const currentPage = params?.page || 1
|
||||
const total = filteredTTS.length
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
return {
|
||||
tts: filteredTTS,
|
||||
total,
|
||||
total_pages: totalPages,
|
||||
current_page: currentPage,
|
||||
}
|
||||
},
|
||||
|
||||
async getProviders(): Promise<TTSProvidersResponse> {
|
||||
return await apiClient.get('/api/v1/tts/providers')
|
||||
},
|
||||
|
||||
async getProvider(name: string): Promise<TTSProvider> {
|
||||
return await apiClient.get(`/api/v1/tts/providers/${name}`)
|
||||
},
|
||||
|
||||
async deleteTTS(ttsId: number): Promise<{ message: string }> {
|
||||
return await apiClient.delete(`/api/v1/tts/${ttsId}`)
|
||||
},
|
||||
}
|
||||
115
src/lib/constants/gtts-languages.ts
Normal file
115
src/lib/constants/gtts-languages.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
export interface LanguageOption {
|
||||
code: string
|
||||
name: string
|
||||
region?: string
|
||||
}
|
||||
|
||||
export const GTTS_LANGUAGES: LanguageOption[] = [
|
||||
{ code: 'af', name: 'Afrikaans' },
|
||||
{ code: 'ar', name: 'Arabic' },
|
||||
{ code: 'bg', name: 'Bulgarian' },
|
||||
{ code: 'bn', name: 'Bengali' },
|
||||
{ code: 'bs', name: 'Bosnian' },
|
||||
{ code: 'ca', name: 'Catalan' },
|
||||
{ code: 'cs', name: 'Czech' },
|
||||
{ code: 'cy', name: 'Welsh' },
|
||||
{ code: 'da', name: 'Danish' },
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'el', name: 'Greek' },
|
||||
{ code: 'en', name: 'English', region: 'United States' },
|
||||
{ code: 'en-au', name: 'English', region: 'Australia' },
|
||||
{ code: 'en-ca', name: 'English', region: 'Canada' },
|
||||
{ code: 'en-gb', name: 'English', region: 'UK' },
|
||||
{ code: 'en-ie', name: 'English', region: 'Ireland' },
|
||||
{ code: 'en-in', name: 'English', region: 'India' },
|
||||
{ code: 'en-ng', name: 'English', region: 'Nigeria' },
|
||||
{ code: 'en-nz', name: 'English', region: 'New Zealand' },
|
||||
{ code: 'en-ph', name: 'English', region: 'Philippines' },
|
||||
{ code: 'en-za', name: 'English', region: 'South Africa' },
|
||||
{ code: 'en-tz', name: 'English', region: 'Tanzania' },
|
||||
{ code: 'en-uk', name: 'English', region: 'United Kingdom' },
|
||||
{ code: 'en-us', name: 'English', region: 'United States' },
|
||||
{ code: 'eo', name: 'Esperanto' },
|
||||
{ code: 'es', name: 'Spanish', region: 'Spain' },
|
||||
{ code: 'es-es', name: 'Spanish', region: 'Spain' },
|
||||
{ code: 'es-mx', name: 'Spanish', region: 'Mexico' },
|
||||
{ code: 'es-us', name: 'Spanish', region: 'United States' },
|
||||
{ code: 'et', name: 'Estonian' },
|
||||
{ code: 'eu', name: 'Basque' },
|
||||
{ code: 'fa', name: 'Persian' },
|
||||
{ code: 'fi', name: 'Finnish' },
|
||||
{ code: 'fr', name: 'French', region: 'France' },
|
||||
{ code: 'fr-ca', name: 'French', region: 'Canada' },
|
||||
{ code: 'fr-fr', name: 'French', region: 'France' },
|
||||
{ code: 'ga', name: 'Irish' },
|
||||
{ code: 'gu', name: 'Gujarati' },
|
||||
{ code: 'he', name: 'Hebrew' },
|
||||
{ code: 'hi', name: 'Hindi' },
|
||||
{ code: 'hr', name: 'Croatian' },
|
||||
{ code: 'hu', name: 'Hungarian' },
|
||||
{ code: 'hy', name: 'Armenian' },
|
||||
{ code: 'id', name: 'Indonesian' },
|
||||
{ code: 'is', name: 'Icelandic' },
|
||||
{ code: 'it', name: 'Italian' },
|
||||
{ code: 'ja', name: 'Japanese' },
|
||||
{ code: 'jw', name: 'Javanese' },
|
||||
{ code: 'ka', name: 'Georgian' },
|
||||
{ code: 'kk', name: 'Kazakh' },
|
||||
{ code: 'km', name: 'Khmer' },
|
||||
{ code: 'kn', name: 'Kannada' },
|
||||
{ code: 'ko', name: 'Korean' },
|
||||
{ code: 'la', name: 'Latin' },
|
||||
{ code: 'lv', name: 'Latvian' },
|
||||
{ code: 'mk', name: 'Macedonian' },
|
||||
{ code: 'ml', name: 'Malayalam' },
|
||||
{ code: 'mr', name: 'Marathi' },
|
||||
{ code: 'ms', name: 'Malay' },
|
||||
{ code: 'mt', name: 'Maltese' },
|
||||
{ code: 'my', name: 'Myanmar (Burmese)' },
|
||||
{ code: 'ne', name: 'Nepali' },
|
||||
{ code: 'nl', name: 'Dutch' },
|
||||
{ code: 'no', name: 'Norwegian' },
|
||||
{ code: 'pa', name: 'Punjabi' },
|
||||
{ code: 'pl', name: 'Polish' },
|
||||
{ code: 'pt', name: 'Portuguese', region: 'Brazil' },
|
||||
{ code: 'pt-br', name: 'Portuguese', region: 'Brazil' },
|
||||
{ code: 'pt-pt', name: 'Portuguese', region: 'Portugal' },
|
||||
{ code: 'ro', name: 'Romanian' },
|
||||
{ code: 'ru', name: 'Russian' },
|
||||
{ code: 'si', name: 'Sinhala' },
|
||||
{ code: 'sk', name: 'Slovak' },
|
||||
{ code: 'sl', name: 'Slovenian' },
|
||||
{ code: 'sq', name: 'Albanian' },
|
||||
{ code: 'sr', name: 'Serbian' },
|
||||
{ code: 'su', name: 'Sundanese' },
|
||||
{ code: 'sv', name: 'Swedish' },
|
||||
{ code: 'sw', name: 'Swahili' },
|
||||
{ code: 'ta', name: 'Tamil' },
|
||||
{ code: 'te', name: 'Telugu' },
|
||||
{ code: 'th', name: 'Thai' },
|
||||
{ code: 'tl', name: 'Filipino' },
|
||||
{ code: 'tr', name: 'Turkish' },
|
||||
{ code: 'uk', name: 'Ukrainian' },
|
||||
{ code: 'ur', name: 'Urdu' },
|
||||
{ code: 'vi', name: 'Vietnamese' },
|
||||
{ code: 'yo', name: 'Yoruba' },
|
||||
{ code: 'zh', name: 'Chinese (Mandarin)' },
|
||||
{ code: 'zh-cn', name: 'Chinese', region: 'China' },
|
||||
{ code: 'zh-tw', name: 'Chinese', region: 'Taiwan' },
|
||||
{ code: 'zu', name: 'Zulu' }
|
||||
]
|
||||
|
||||
export function getLanguageDisplayName(lang: LanguageOption): string {
|
||||
if (lang.region) {
|
||||
return `${lang.name} (${lang.region}) - ${lang.code}`
|
||||
}
|
||||
return `${lang.name} - ${lang.code}`
|
||||
}
|
||||
|
||||
export function getSortedLanguages(): LanguageOption[] {
|
||||
return [...GTTS_LANGUAGES].sort((a, b) => {
|
||||
const aDisplay = getLanguageDisplayName(a)
|
||||
const bDisplay = getLanguageDisplayName(b)
|
||||
return aDisplay.localeCompare(bDisplay)
|
||||
})
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export const playerEvents = new EventEmitter()
|
||||
export const soundEvents = new EventEmitter()
|
||||
export const userEvents = new EventEmitter()
|
||||
export const extractionEvents = new EventEmitter()
|
||||
export const ttsEvents = new EventEmitter()
|
||||
|
||||
// Auth event types
|
||||
export const AUTH_EVENTS = {
|
||||
@@ -69,3 +70,11 @@ export const EXTRACTION_EVENTS = {
|
||||
EXTRACTION_COMPLETED: 'extraction_completed',
|
||||
EXTRACTION_FAILED: 'extraction_failed',
|
||||
} as const
|
||||
|
||||
// TTS event types
|
||||
export const TTS_EVENTS = {
|
||||
TTS_STATUS_UPDATED: 'tts_status_updated',
|
||||
TTS_CREATED: 'tts_created',
|
||||
TTS_COMPLETED: 'tts_completed',
|
||||
TTS_FAILED: 'tts_failed',
|
||||
} as const
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DashboardHeader } from '@/components/dashboard/DashboardHeader'
|
||||
import { ErrorState, LoadingSkeleton } from '@/components/dashboard/DashboardLoadingStates'
|
||||
import { StatisticsGrid } from '@/components/dashboard/StatisticsGrid'
|
||||
import { TopSoundsSection } from '@/components/dashboard/TopSoundsSection'
|
||||
import { TopUsersSection } from '@/components/dashboard/TopUsersSection'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
interface SoundboardStatistics {
|
||||
@@ -19,6 +20,13 @@ interface TrackStatistics {
|
||||
total_size: number
|
||||
}
|
||||
|
||||
interface TTSStatistics {
|
||||
sound_count: number
|
||||
total_play_count: number
|
||||
total_duration: number
|
||||
total_size: number
|
||||
}
|
||||
|
||||
interface TopSound {
|
||||
id: number
|
||||
name: string
|
||||
@@ -28,11 +36,19 @@ interface TopSound {
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
interface TopUser {
|
||||
id: number
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [soundboardStatistics, setSoundboardStatistics] =
|
||||
useState<SoundboardStatistics | null>(null)
|
||||
const [trackStatistics, setTrackStatistics] =
|
||||
useState<TrackStatistics | null>(null)
|
||||
const [ttsStatistics, setTtsStatistics] =
|
||||
useState<TTSStatistics | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -44,6 +60,13 @@ export function DashboardPage() {
|
||||
const [limit, setLimit] = useState(5)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
// Top users state
|
||||
const [topUsers, setTopUsers] = useState<TopUser[]>([])
|
||||
const [topUsersLoading, setTopUsersLoading] = useState(false)
|
||||
const [metricType, setMetricType] = useState('sounds_played')
|
||||
const [userPeriod, setUserPeriod] = useState('all_time')
|
||||
const [userLimit, setUserLimit] = useState(5)
|
||||
|
||||
const fetchStatistics = useCallback(async () => {
|
||||
try {
|
||||
setError(null) // Clear previous errors
|
||||
@@ -74,6 +97,19 @@ export function DashboardPage() {
|
||||
const trackData = await trackResponse.json()
|
||||
setTrackStatistics(trackData)
|
||||
|
||||
// Fetch TTS statistics separately to avoid Promise.all failures
|
||||
const ttsResponse = await fetch('/api/v1/dashboard/tts-statistics', {
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (!ttsResponse.ok) {
|
||||
const errorText = await ttsResponse.text()
|
||||
throw new Error(`Failed to fetch TTS statistics: ${errorText}`)
|
||||
}
|
||||
|
||||
const ttsData = await ttsResponse.json()
|
||||
setTtsStatistics(ttsData)
|
||||
|
||||
} catch (err) {
|
||||
console.error('Dashboard statistics error:', err)
|
||||
setError(err instanceof Error ? err.message : 'An error occurred')
|
||||
@@ -134,18 +170,70 @@ export function DashboardPage() {
|
||||
[soundType, period, limit],
|
||||
)
|
||||
|
||||
const fetchTopUsers = useCallback(
|
||||
async (showLoading = false) => {
|
||||
try {
|
||||
if (showLoading) {
|
||||
setTopUsersLoading(true)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/dashboard/top-users?metric_type=${metricType}&period=${userPeriod}&limit=${userLimit}`,
|
||||
{ credentials: 'include' },
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch top users')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Graceful update: merge new data while preserving animations
|
||||
setTopUsers(prevTopUsers => {
|
||||
// Create a map of existing users for efficient lookup
|
||||
const existingUsersMap = new Map(
|
||||
prevTopUsers.map(user => [user.id, user]),
|
||||
)
|
||||
|
||||
// Update existing users and add new ones
|
||||
return data.map((newUser: TopUser) => {
|
||||
const existingUser = existingUsersMap.get(newUser.id)
|
||||
if (existingUser) {
|
||||
// Preserve object reference if data hasn't changed to avoid re-renders
|
||||
if (
|
||||
existingUser.name === newUser.name &&
|
||||
existingUser.count === newUser.count
|
||||
) {
|
||||
return existingUser
|
||||
}
|
||||
}
|
||||
return newUser
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch top users:', err)
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setTopUsersLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[metricType, userPeriod, userLimit],
|
||||
)
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
// Fetch statistics and top sounds sequentially to avoid Promise.all issues
|
||||
await fetchStatistics()
|
||||
await fetchTopSounds()
|
||||
await fetchTopUsers()
|
||||
} catch (err) {
|
||||
console.error('Error during refresh:', err)
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [fetchStatistics, fetchTopSounds])
|
||||
}, [fetchStatistics, fetchTopSounds, fetchTopUsers])
|
||||
|
||||
const retryFromError = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -174,23 +262,27 @@ export function DashboardPage() {
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
// Only auto-refresh if not currently loading or in error state
|
||||
if (!loading && !refreshing && (!error || (soundboardStatistics && trackStatistics))) {
|
||||
if (!loading && !refreshing && (!error || (soundboardStatistics && trackStatistics && ttsStatistics))) {
|
||||
refreshAll()
|
||||
}
|
||||
}, 30000) // Increased to 30 seconds
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [refreshAll, loading, refreshing, error, soundboardStatistics, trackStatistics])
|
||||
}, [refreshAll, loading, refreshing, error, soundboardStatistics, trackStatistics, ttsStatistics])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopSounds(true) // Show loading on initial load and filter changes
|
||||
}, [fetchTopSounds])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopUsers(true) // Show loading on initial load and filter changes
|
||||
}, [fetchTopUsers])
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSkeleton />
|
||||
}
|
||||
|
||||
if (error && (!soundboardStatistics || !trackStatistics)) {
|
||||
if (error && (!soundboardStatistics || !trackStatistics || !ttsStatistics)) {
|
||||
return <ErrorState error={error} onRetry={retryFromError} />
|
||||
}
|
||||
|
||||
@@ -204,10 +296,11 @@ export function DashboardPage() {
|
||||
<DashboardHeader onRefresh={refreshAll} isRefreshing={refreshing} />
|
||||
|
||||
<div className="space-y-6">
|
||||
{soundboardStatistics && trackStatistics && (
|
||||
{soundboardStatistics && trackStatistics && ttsStatistics && (
|
||||
<StatisticsGrid
|
||||
soundboardStatistics={soundboardStatistics}
|
||||
trackStatistics={trackStatistics}
|
||||
ttsStatistics={ttsStatistics}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -221,6 +314,17 @@ export function DashboardPage() {
|
||||
onPeriodChange={setPeriod}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
|
||||
<TopUsersSection
|
||||
topUsers={topUsers}
|
||||
loading={topUsersLoading}
|
||||
metricType={metricType}
|
||||
period={userPeriod}
|
||||
limit={userLimit}
|
||||
onMetricTypeChange={setMetricType}
|
||||
onPeriodChange={setUserPeriod}
|
||||
onLimitChange={setUserLimit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { SimpleSortableRow } from '@/components/playlists/playlist-edit/SimpleSo
|
||||
import { SortableTableRow } from '@/components/playlists/playlist-edit/SortableTableRow'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
@@ -46,7 +47,7 @@ import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { Minus, Music, Plus, RefreshCw } from 'lucide-react'
|
||||
import { Minus, Music, Plus, RefreshCw, Search, X } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
@@ -74,6 +75,7 @@ export function PlaylistEditPage() {
|
||||
Sound | PlaylistSound | null
|
||||
>(null)
|
||||
const [dropPosition, setDropPosition] = useState<number | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// dnd-kit sensors
|
||||
const sensors = useSensors(
|
||||
@@ -152,10 +154,18 @@ export function PlaylistEditPage() {
|
||||
if (!isAddMode) {
|
||||
// Entering add mode - fetch available sounds
|
||||
await fetchAvailableSounds()
|
||||
} else {
|
||||
// Exiting add mode - clear search
|
||||
setSearchQuery('')
|
||||
}
|
||||
setIsAddMode(!isAddMode)
|
||||
}
|
||||
|
||||
// Filter available sounds based on search query
|
||||
const filteredAvailableSounds = availableSounds.filter(sound =>
|
||||
sound.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNaN(playlistId)) {
|
||||
fetchPlaylist()
|
||||
@@ -807,9 +817,30 @@ export function PlaylistEditPage() {
|
||||
|
||||
{/* Available Sounds */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm text-muted-foreground mb-3">
|
||||
Available EXT Sounds ({availableSounds.length})
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium text-sm text-muted-foreground">
|
||||
Available EXT Sounds ({filteredAvailableSounds.length}/{availableSounds.length})
|
||||
</h4>
|
||||
</div>
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search sounds..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 pr-8"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{availableSoundsLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
@@ -824,15 +855,23 @@ export function PlaylistEditPage() {
|
||||
All EXT sounds are already in this playlist
|
||||
</p>
|
||||
</div>
|
||||
) : filteredAvailableSounds.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Search className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No sounds match your search</p>
|
||||
<p className="text-xs mt-1">
|
||||
Try a different search term
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<SortableContext
|
||||
items={availableSounds.map(
|
||||
items={filteredAvailableSounds.map(
|
||||
sound => `available-sound-${sound.id}`,
|
||||
)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{availableSounds.map(sound => (
|
||||
{filteredAvailableSounds.map(sound => (
|
||||
<AvailableSound
|
||||
key={sound.id}
|
||||
sound={sound}
|
||||
|
||||
@@ -189,6 +189,53 @@ export function PlaylistsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePlaylist = async (playlist: Playlist) => {
|
||||
// Protect main playlist from deletion
|
||||
if (playlist.is_main) {
|
||||
toast.error('The main playlist cannot be deleted')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if playlist is deletable
|
||||
if (!playlist.is_deletable) {
|
||||
toast.error('This playlist cannot be deleted')
|
||||
return
|
||||
}
|
||||
|
||||
// Confirm deletion
|
||||
const confirmMessage = `Are you sure you want to delete the playlist "${playlist.name}"?${
|
||||
playlist.sound_count > 0
|
||||
? `\n\nThis playlist contains ${playlist.sound_count} sound${playlist.sound_count !== 1 ? 's' : ''}. The sounds will not be deleted, only removed from this playlist.`
|
||||
: ''
|
||||
}\n\nThis action cannot be undone.`
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await playlistsService.deletePlaylist(playlist.id)
|
||||
toast.success(`Playlist "${playlist.name}" deleted successfully`)
|
||||
|
||||
// Remove the deleted playlist from the local state
|
||||
setPlaylists(prevPlaylists =>
|
||||
prevPlaylists.filter(p => p.id !== playlist.id)
|
||||
)
|
||||
|
||||
// Update total count
|
||||
setTotalCount(prev => prev - 1)
|
||||
|
||||
// If current page is now empty and not the first page, go to previous page
|
||||
const remainingOnCurrentPage = playlists.length - 1
|
||||
if (remainingOnCurrentPage === 0 && currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to delete playlist'
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <PlaylistsLoading />
|
||||
@@ -209,6 +256,7 @@ export function PlaylistsPage() {
|
||||
onEdit={handleEditPlaylist}
|
||||
onSetCurrent={handleSetCurrent}
|
||||
onFavoriteToggle={handleFavoriteToggle}
|
||||
onDelete={handleDeletePlaylist}
|
||||
/>
|
||||
<AppPagination
|
||||
currentPage={currentPage}
|
||||
|
||||
@@ -100,11 +100,6 @@ export function SchedulersPage() {
|
||||
setShowCreateDialog(false)
|
||||
}
|
||||
|
||||
const handleTaskUpdated = (updatedTask: ScheduledTask) => {
|
||||
setTasks(prev => prev.map(task =>
|
||||
task.id === updatedTask.id ? updatedTask : task
|
||||
))
|
||||
}
|
||||
|
||||
const handleTaskDeleted = (taskId: number) => {
|
||||
setTasks(prev => prev.filter(task => task.id !== taskId))
|
||||
@@ -132,7 +127,6 @@ export function SchedulersPage() {
|
||||
return (
|
||||
<SchedulersTable
|
||||
tasks={tasks}
|
||||
onTaskUpdated={handleTaskUpdated}
|
||||
onTaskDeleted={handleTaskDeleted}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ import { SOUND_EVENTS, soundEvents } from '@/lib/events'
|
||||
import { useSocket } from '@/contexts/SocketContext'
|
||||
import {
|
||||
AlertCircle,
|
||||
Filter,
|
||||
Heart,
|
||||
RefreshCw,
|
||||
Search,
|
||||
@@ -90,6 +91,7 @@ export function SoundsPage() {
|
||||
const [sortBy, setSortBy] = useState<SoundSortField>('name')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||||
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false)
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | 'SDB' | 'TTS'>('all')
|
||||
|
||||
const handlePlaySound = async (sound: Sound) => {
|
||||
// If WebSocket is connected, use WebSocket for immediate response
|
||||
@@ -159,13 +161,18 @@ export function SoundsPage() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const sdbSounds = await soundsService.getSDBSounds({
|
||||
|
||||
// Determine types to filter by
|
||||
const types = typeFilter === 'all' ? ['SDB', 'TTS'] : [typeFilter]
|
||||
|
||||
const sounds = await soundsService.getSounds({
|
||||
types,
|
||||
search: debouncedSearchQuery.trim() || undefined,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
favorites_only: showFavoritesOnly,
|
||||
})
|
||||
setSounds(sdbSounds)
|
||||
setSounds(sounds)
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to fetch sounds'
|
||||
@@ -189,7 +196,7 @@ export function SoundsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchSounds()
|
||||
}, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly])
|
||||
}, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly, typeFilter])
|
||||
|
||||
// Listen for sound_played events and update play_count
|
||||
useEffect(() => {
|
||||
@@ -290,7 +297,9 @@ export function SoundsPage() {
|
||||
<p className="text-muted-foreground">
|
||||
{showFavoritesOnly
|
||||
? 'You haven\'t favorited any sounds yet. Click the heart icon on sounds to add them to your favorites.'
|
||||
: 'No SDB type sounds are available in your library.'
|
||||
: typeFilter === 'all'
|
||||
? 'No sounds are available in your library.'
|
||||
: `No ${typeFilter} type sounds are available in your library.`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@@ -298,7 +307,7 @@ export function SoundsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{sounds.map((sound, idx) => (
|
||||
<SoundCard
|
||||
key={sound.id}
|
||||
@@ -362,6 +371,21 @@ export function SoundsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onValueChange={value => setTypeFilter(value as 'all' | 'SDB' | 'TTS')}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="SDB">Soundboard</SelectItem>
|
||||
<SelectItem value="TTS">TTS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={value => setSortBy(value as SoundSortField)}
|
||||
|
||||
201
src/pages/TTSPage.tsx
Normal file
201
src/pages/TTSPage.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { AppPagination } from '@/components/AppPagination'
|
||||
import { CreateTTSDialog } from '@/components/tts/CreateTTSDialog'
|
||||
import { TTSHeader } from '@/components/tts/TTSHeader'
|
||||
import {
|
||||
TTSEmpty,
|
||||
TTSError,
|
||||
TTSLoading,
|
||||
} from '@/components/tts/TTSLoadingStates'
|
||||
import { TTSTable } from '@/components/tts/TTSTable'
|
||||
import {
|
||||
type TTSResponse,
|
||||
type TTSSortField,
|
||||
type TTSSortOrder,
|
||||
ttsService,
|
||||
} from '@/lib/api/services/tts'
|
||||
import { TTS_EVENTS, ttsEvents } from '@/lib/events'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function TTSPage() {
|
||||
const [ttsHistory, setTTSHistory] = useState<TTSResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Search and sorting state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<TTSSortField>('created_at')
|
||||
const [sortOrder, setSortOrder] = useState<TTSSortOrder>('desc')
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
// Create TTS dialog state
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
|
||||
// Debounce search query
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery)
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(handler)
|
||||
}, [searchQuery])
|
||||
|
||||
const fetchTTSHistory = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await ttsService.getTTSHistory({
|
||||
search: debouncedSearchQuery.trim() || undefined,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
})
|
||||
setTTSHistory(response.tts)
|
||||
setTotalPages(response.total_pages)
|
||||
setTotalCount(response.total)
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to fetch TTS history'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [debouncedSearchQuery, sortBy, sortOrder, currentPage, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTTSHistory()
|
||||
}, [fetchTTSHistory])
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
useEffect(() => {
|
||||
if (currentPage !== 1) {
|
||||
setCurrentPage(1)
|
||||
}
|
||||
}, [debouncedSearchQuery, sortBy, sortOrder, pageSize])
|
||||
|
||||
// Listen for TTS events to refresh the list
|
||||
useEffect(() => {
|
||||
const handleTTSCompleted = () => {
|
||||
fetchTTSHistory()
|
||||
}
|
||||
|
||||
const handleTTSFailed = () => {
|
||||
fetchTTSHistory()
|
||||
}
|
||||
|
||||
const handleTTSCreated = () => {
|
||||
fetchTTSHistory()
|
||||
}
|
||||
|
||||
// Subscribe to TTS events
|
||||
ttsEvents.on(TTS_EVENTS.TTS_COMPLETED, handleTTSCompleted)
|
||||
ttsEvents.on(TTS_EVENTS.TTS_FAILED, handleTTSFailed)
|
||||
ttsEvents.on(TTS_EVENTS.TTS_CREATED, handleTTSCreated)
|
||||
|
||||
return () => {
|
||||
// Cleanup event listeners
|
||||
ttsEvents.off(TTS_EVENTS.TTS_COMPLETED, handleTTSCompleted)
|
||||
ttsEvents.off(TTS_EVENTS.TTS_FAILED, handleTTSFailed)
|
||||
ttsEvents.off(TTS_EVENTS.TTS_CREATED, handleTTSCreated)
|
||||
}
|
||||
}, [fetchTTSHistory])
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page)
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size)
|
||||
setCurrentPage(1) // Reset to first page when changing page size
|
||||
}
|
||||
|
||||
const handleTTSDeleted = (ttsId: number) => {
|
||||
// Remove the deleted TTS from the current list
|
||||
setTTSHistory(prev => prev.filter(tts => tts.id !== ttsId))
|
||||
|
||||
// Update total count
|
||||
setTotalCount(prev => prev - 1)
|
||||
|
||||
// If current page is now empty and not the first page, go to previous page
|
||||
const remainingOnCurrentPage = ttsHistory.length - 1
|
||||
if (remainingOnCurrentPage === 0 && currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1)
|
||||
}
|
||||
|
||||
// Refresh the full list to ensure accuracy
|
||||
fetchTTSHistory()
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <TTSLoading />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <TTSError error={error} onRetry={fetchTTSHistory} />
|
||||
}
|
||||
|
||||
if (!ttsHistory || ttsHistory.length === 0) {
|
||||
return <TTSEmpty searchQuery={searchQuery} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TTSTable
|
||||
ttsHistory={ttsHistory}
|
||||
onTTSDeleted={handleTTSDeleted}
|
||||
/>
|
||||
<AppPagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalCount={totalCount}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
itemName="TTS generations"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [{ label: 'Dashboard', href: '/' }, { label: 'Text to Speech' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
<TTSHeader
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
sortBy={sortBy}
|
||||
onSortByChange={setSortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortOrderChange={setSortOrder}
|
||||
onRefresh={fetchTTSHistory}
|
||||
onCreateClick={() => setShowCreateDialog(true)}
|
||||
loading={loading}
|
||||
error={error}
|
||||
ttsCount={totalCount}
|
||||
/>
|
||||
|
||||
<CreateTTSDialog
|
||||
open={showCreateDialog}
|
||||
onOpenChange={setShowCreateDialog}
|
||||
/>
|
||||
|
||||
{renderContent()}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,61 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false, // Disable source maps in production for security
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// React core
|
||||
'react': ['react', 'react-dom', 'react-router'],
|
||||
|
||||
// UI library - Radix UI components
|
||||
'radix-ui': [
|
||||
'@radix-ui/react-avatar',
|
||||
'@radix-ui/react-checkbox',
|
||||
'@radix-ui/react-context-menu',
|
||||
'@radix-ui/react-dialog',
|
||||
'@radix-ui/react-dropdown-menu',
|
||||
'@radix-ui/react-label',
|
||||
'@radix-ui/react-popover',
|
||||
'@radix-ui/react-progress',
|
||||
'@radix-ui/react-scroll-area',
|
||||
'@radix-ui/react-select',
|
||||
'@radix-ui/react-separator',
|
||||
'@radix-ui/react-slider',
|
||||
'@radix-ui/react-slot',
|
||||
'@radix-ui/react-switch',
|
||||
'@radix-ui/react-tabs',
|
||||
'@radix-ui/react-tooltip',
|
||||
],
|
||||
|
||||
// Drag and drop
|
||||
'dnd-kit': [
|
||||
'@dnd-kit/core',
|
||||
'@dnd-kit/sortable',
|
||||
'@dnd-kit/utilities',
|
||||
],
|
||||
|
||||
// Utilities
|
||||
'utils': [
|
||||
'clsx',
|
||||
'tailwind-merge',
|
||||
'class-variance-authority',
|
||||
'date-fns',
|
||||
],
|
||||
|
||||
// Other libraries
|
||||
'misc': [
|
||||
'recharts',
|
||||
'socket.io-client',
|
||||
'sonner',
|
||||
'next-themes',
|
||||
'cmdk',
|
||||
'react-day-picker',
|
||||
'@number-flow/react',
|
||||
'lucide-react',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Preview server configuration (for testing built version)
|
||||
preview: {
|
||||
|
||||
Reference in New Issue
Block a user