Compare commits
130 Commits
59f160609b
...
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 | ||
|
|
2281993edb | ||
|
|
4fe9251a2d | ||
|
|
4fe280cf5c | ||
|
|
24cc0cc45f | ||
|
|
f7bfd3de73 | ||
|
|
43b03e61bd | ||
|
|
2babeba49e | ||
|
|
92444fb023 | ||
|
|
cd7af24831 | ||
|
|
d4b87aafe3 | ||
|
|
37c932fe75 | ||
|
|
1ba6f23999 | ||
|
|
dba08e2ec0 | ||
|
|
aa11ec379d | ||
|
|
7982a2eb6d | ||
|
|
5afb761d3c | ||
|
|
9603daa5ce | ||
|
|
2ec58ea268 | ||
|
|
df60b5ce93 | ||
|
|
80a18575a1 | ||
|
|
74dfec2e29 | ||
|
|
282ba9446d | ||
|
|
d7b1d97a28 | ||
|
|
7e03189fc4 | ||
|
|
a0d5840166 | ||
|
|
25eacbc85f | ||
|
|
28faf9b149 | ||
|
|
851738f04f | ||
|
|
70de6ad919 | ||
|
|
40b053c446 | ||
|
|
4251057668 | ||
|
|
009780e64c | ||
|
|
6a40311a82 | ||
|
|
4a973e5044 | ||
|
|
8f233aaef7 | ||
|
|
e029a692a6 | ||
|
|
b1eb5c4ab2 | ||
|
|
64226f76c1 | ||
|
|
ca57a7a04f | ||
|
|
77f24ea4ff | ||
|
|
b76b34ea4f | ||
|
|
46bfcad271 | ||
|
|
75ecd26e06 | ||
|
|
04401092bb | ||
|
|
ed888dd8d1 | ||
|
|
0024f1d647 | ||
|
|
af1d543669 | ||
|
|
ad466e2f91 | ||
|
|
1027a67e37 | ||
|
|
2e41d5b695 | ||
|
|
ecb17e9f94 | ||
|
|
f6117ededd | ||
|
|
7b01ace746 | ||
|
|
9cfa1f6a28 | ||
|
|
cd654b8777 | ||
|
|
32140d7b5a | ||
|
|
83f400acbb | ||
|
|
1e76516cfc | ||
|
|
907a5df5c7 | ||
|
|
c6912f5a92 | ||
|
|
4e50e7e79d | ||
|
|
8358aa16aa | ||
|
|
5574eeb809 | ||
|
|
4e68344f23 | ||
|
|
7ebeac1280 | ||
|
|
ccd5973db9 | ||
|
|
fb80806819 | ||
|
|
e55c5fd4b9 | ||
|
|
ee05bc8a64 | ||
|
|
53e5ec74d8 | ||
|
|
25fd92e0da | ||
|
|
fc9cdf1065 | ||
|
|
dbbb9538dd | ||
|
|
0897095942 | ||
|
|
5182ed36c3 | ||
|
|
490221ffdd | ||
|
|
d80d8588f6 | ||
|
|
0c7875cac5 | ||
|
|
34f20f33af | ||
|
|
9c01cd538e | ||
|
|
6eb023a63c | ||
|
|
3d16b36ee9 | ||
|
|
b47486aaf9 | ||
|
|
f4e951db3c | ||
|
|
3084efe139 | ||
|
|
0c1c420fd8 | ||
|
|
f2772c392c | ||
|
|
936d48fbb9 |
73
.gitea/workflows/test.yml
Normal file
73
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,73 @@
|
||||
name: Frontend CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
# with:
|
||||
# path: ~/.bun/install/cache
|
||||
# key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lockb') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-bun-
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Run ESLint
|
||||
run: bun run lint
|
||||
|
||||
- name: Run TypeScript check
|
||||
run: bunx tsc -b --noEmit
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
# with:
|
||||
# path: ~/.bun/install/cache
|
||||
# key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lockb') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-bun-
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Build application
|
||||
run: bun run build
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/
|
||||
retention-days: 7
|
||||
371
bun.lock
371
bun.lock
@@ -4,57 +4,60 @@
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@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",
|
||||
},
|
||||
},
|
||||
@@ -64,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=="],
|
||||
|
||||
@@ -84,7 +101,15 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||
|
||||
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
|
||||
|
||||
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="],
|
||||
|
||||
@@ -138,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=="],
|
||||
|
||||
@@ -180,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=="],
|
||||
@@ -198,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=="],
|
||||
|
||||
@@ -212,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=="],
|
||||
|
||||
@@ -230,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=="],
|
||||
|
||||
@@ -284,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=="],
|
||||
|
||||
@@ -328,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=="],
|
||||
|
||||
@@ -412,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=="],
|
||||
|
||||
@@ -476,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=="],
|
||||
@@ -504,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=="],
|
||||
@@ -552,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=="],
|
||||
|
||||
@@ -596,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=="],
|
||||
|
||||
@@ -606,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=="],
|
||||
|
||||
@@ -622,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=="],
|
||||
|
||||
@@ -682,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=="],
|
||||
@@ -718,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=="],
|
||||
|
||||
@@ -736,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=="],
|
||||
|
||||
@@ -748,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=="],
|
||||
@@ -788,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=="],
|
||||
|
||||
@@ -800,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=="],
|
||||
|
||||
@@ -820,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=="],
|
||||
|
||||
@@ -862,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=="],
|
||||
|
||||
@@ -876,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=="],
|
||||
|
||||
@@ -890,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=="],
|
||||
|
||||
@@ -910,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=="],
|
||||
|
||||
@@ -934,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=="],
|
||||
|
||||
@@ -958,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=="],
|
||||
@@ -966,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=="],
|
||||
@@ -982,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=="],
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<title>SDB v2</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
73
package.json
73
package.json
@@ -1,67 +1,70 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"name": "sdb",
|
||||
"description": "Frontend for the SDB v2",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 8001",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:production": "tsc -b && vite build --mode production",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview --port 8001"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
175
src/App.tsx
175
src/App.tsx
@@ -1,23 +1,48 @@
|
||||
import { Routes, Route, Navigate } from 'react-router'
|
||||
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 { LoginPage } from './pages/LoginPage'
|
||||
import { RegisterPage } from './pages/RegisterPage'
|
||||
import { AuthCallbackPage } from './pages/AuthCallbackPage'
|
||||
import { DashboardPage } from './pages/DashboardPage'
|
||||
import { SoundsPage } from './pages/SoundsPage'
|
||||
import { PlaylistsPage } from './pages/PlaylistsPage'
|
||||
import { ExtractionsPage } from './pages/ExtractionsPage'
|
||||
import { UsersPage } from './pages/admin/UsersPage'
|
||||
import { SettingsPage } from './pages/admin/SettingsPage'
|
||||
import { Toaster } from './components/ui/sonner'
|
||||
|
||||
// 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()
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
@@ -27,58 +52,148 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { user } = useAuth()
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} />
|
||||
<Route
|
||||
path="/login"
|
||||
element={user ? <Navigate to="/" replace /> : <LoginPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={user ? <Navigate to="/" replace /> : <RegisterPage />}
|
||||
/>
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route path="/" element={
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/sounds" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/sounds"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SoundsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/playlists" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/playlists"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PlaylistsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/extractions" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/playlists/:id/edit"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PlaylistEditPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/extractions"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ExtractionsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/admin/users" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tts"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TTSPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/sequencer"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SequencerPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/schedulers"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SchedulersPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/account"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AccountPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<UsersPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/admin/settings" element={
|
||||
<ProtectedRoute>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/settings"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<SettingsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<LocaleProvider defaultLocale="fr-FR" defaultTimezone="Europe/Paris">
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<AuthProvider>
|
||||
<SocketProvider>
|
||||
<AppRoutes />
|
||||
<Toaster richColors />
|
||||
<Toaster richColors position='top-center' />
|
||||
</SocketProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</LocaleProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { AppSidebar } from './AppSidebar'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -9,7 +6,16 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb'
|
||||
import { Player } from './player/Player'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AppSidebar } from './AppSidebar'
|
||||
import { GlobalSearch } from './GlobalSearch'
|
||||
import { Player, type PlayerDisplayMode } from './player/Player'
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode
|
||||
@@ -22,9 +28,45 @@ interface AppLayoutProps {
|
||||
}
|
||||
|
||||
export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
|
||||
const [playerDisplayMode, setPlayerDisplayMode] = useState<PlayerDisplayMode>(
|
||||
() => {
|
||||
// Initialize from localStorage or default to 'normal'
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem(
|
||||
'playerDisplayMode',
|
||||
) as PlayerDisplayMode
|
||||
return saved &&
|
||||
['normal', 'minimized', 'maximized', 'sidebar'].includes(saved)
|
||||
? saved
|
||||
: 'normal'
|
||||
}
|
||||
return 'normal'
|
||||
},
|
||||
)
|
||||
|
||||
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 />
|
||||
<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">
|
||||
@@ -54,11 +96,10 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">{children}</div>
|
||||
</SidebarInset>
|
||||
<Player />
|
||||
<Player onPlayerModeChange={setPlayerDisplayMode} />
|
||||
<GlobalSearch isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
147
src/components/AppPagination.tsx
Normal file
147
src/components/AppPagination.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
interface AppPaginationProps {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
totalCount: number
|
||||
pageSize: number
|
||||
pageSizeOptions?: number[]
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange: (size: number) => void
|
||||
itemName?: string // e.g., "items", "extractions", "playlists"
|
||||
}
|
||||
|
||||
export function AppPagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalCount,
|
||||
pageSize,
|
||||
pageSizeOptions = [10, 20, 50, 100],
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
itemName = 'items',
|
||||
}: AppPaginationProps) {
|
||||
// Don't render if there are no items
|
||||
if (totalCount === 0) return null
|
||||
|
||||
const getVisiblePages = () => {
|
||||
const delta = 2
|
||||
const range = []
|
||||
const rangeWithDots = []
|
||||
|
||||
for (
|
||||
let i = Math.max(2, currentPage - delta);
|
||||
i <= Math.min(totalPages - 1, currentPage + delta);
|
||||
i++
|
||||
) {
|
||||
range.push(i)
|
||||
}
|
||||
|
||||
if (currentPage - delta > 2) {
|
||||
rangeWithDots.push(1, '...')
|
||||
} else {
|
||||
rangeWithDots.push(1)
|
||||
}
|
||||
|
||||
rangeWithDots.push(...range)
|
||||
|
||||
if (currentPage + delta < totalPages - 1) {
|
||||
rangeWithDots.push('...', totalPages)
|
||||
} else if (totalPages > 1) {
|
||||
rangeWithDots.push(totalPages)
|
||||
}
|
||||
|
||||
return rangeWithDots
|
||||
}
|
||||
|
||||
const startItem = Math.min((currentPage - 1) * pageSize + 1, totalCount)
|
||||
const endItem = Math.min(currentPage * pageSize, totalCount)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Showing {startItem} to {endItem} of {totalCount} {itemName}
|
||||
</p>
|
||||
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (currentPage > 1) onPageChange(currentPage - 1)
|
||||
}}
|
||||
className={currentPage <= 1 ? 'pointer-events-none opacity-50' : ''}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getVisiblePages().map((page, index) => (
|
||||
<PaginationItem key={index}>
|
||||
{page === '...' ? (
|
||||
<PaginationEllipsis />
|
||||
) : (
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onPageChange(page as number)
|
||||
}}
|
||||
isActive={currentPage === page}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
)}
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (currentPage < totalPages) onPageChange(currentPage + 1)
|
||||
}}
|
||||
className={currentPage >= totalPages ? 'pointer-events-none opacity-50' : ''}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
|
||||
<div className="flex items-center gap-2 whitespace-nowrap">
|
||||
<span className="text-sm text-muted-foreground">Show</span>
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={value => onPageSizeChange(parseInt(value, 10))}
|
||||
>
|
||||
<SelectTrigger className="w-[75px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map(size => (
|
||||
<SelectItem key={size} value={size.toString()}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">rows</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
Home,
|
||||
Music,
|
||||
Users,
|
||||
Settings,
|
||||
Download,
|
||||
PlayCircle
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -13,12 +7,32 @@ import {
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
CalendarClock,
|
||||
Download,
|
||||
Home,
|
||||
Music,
|
||||
PlayCircle,
|
||||
Settings,
|
||||
Users,
|
||||
AudioLines,
|
||||
Mic,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import { CreditsNav } from './nav/CreditsNav'
|
||||
import { NavGroup } from './nav/NavGroup'
|
||||
import { NavItem } from './nav/NavItem'
|
||||
import { StopSoundsButton } from './nav/StopSoundsButton'
|
||||
import { UserNav } from './nav/UserNav'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { CompactPlayer } from './player/CompactPlayer'
|
||||
|
||||
export function AppSidebar() {
|
||||
interface AppSidebarProps {
|
||||
showCompactPlayer?: boolean
|
||||
onSearchClick?: () => void
|
||||
}
|
||||
|
||||
export function AppSidebar({ showCompactPlayer = false, onSearchClick }: AppSidebarProps) {
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
if (!user) return null
|
||||
@@ -28,19 +42,38 @@ export function AppSidebar() {
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2 py-2">
|
||||
<Music className="h-6 w-6" />
|
||||
<span className="font-semibold text-lg group-data-[collapsible=icon]:hidden">Soundboard</span>
|
||||
<span className="font-semibold text-lg group-data-[collapsible=icon]:hidden">
|
||||
SDB v2
|
||||
</span>
|
||||
</div>
|
||||
</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="/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" && (
|
||||
{user.role === 'admin' && (
|
||||
<NavGroup label="Admin">
|
||||
<NavItem href="/admin/users" icon={Users} title="Users" />
|
||||
<NavItem href="/admin/settings" icon={Settings} title="Settings" />
|
||||
@@ -49,6 +82,19 @@ export function AppSidebar() {
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
{showCompactPlayer && (
|
||||
<>
|
||||
<Separator className="mb-1 group-data-[collapsible=icon]:hidden" />
|
||||
<div className="p-2">
|
||||
<CompactPlayer />
|
||||
</div>
|
||||
<Separator className="mb-1 group-data-[collapsible=icon]:hidden" />
|
||||
</>
|
||||
)}
|
||||
<StopSoundsButton />
|
||||
<Separator className="mb-1 group-data-[collapsible=icon]:hidden" />
|
||||
<CreditsNav user={user} />
|
||||
<Separator className="mb-1 group-data-[collapsible=icon]:hidden" />
|
||||
<UserNav user={user} logout={logout} />
|
||||
</SidebarFooter>
|
||||
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
70
src/components/LocaleProvider.tsx
Normal file
70
src/components/LocaleProvider.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { type Locale, LocaleProviderContext } from '@/contexts/LocaleContext'
|
||||
import { getSupportedTimezones } from '@/utils/locale'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type LocaleProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultLocale?: Locale
|
||||
defaultTimezone?: string
|
||||
localeStorageKey?: string
|
||||
timezoneStorageKey?: string
|
||||
}
|
||||
|
||||
export function LocaleProvider({
|
||||
children,
|
||||
defaultLocale = 'fr-FR',
|
||||
defaultTimezone = 'Europe/Paris',
|
||||
localeStorageKey = 'locale',
|
||||
timezoneStorageKey = 'timezone',
|
||||
...props
|
||||
}: LocaleProviderProps) {
|
||||
const [locale, setLocaleState] = useState<Locale>(() => {
|
||||
const stored = localStorage.getItem(localeStorageKey) as Locale
|
||||
const validLocale = stored && ['en-US', 'fr-FR'].includes(stored) ? stored : defaultLocale
|
||||
|
||||
// Set default in localStorage if not present
|
||||
if (!stored) {
|
||||
localStorage.setItem(localeStorageKey, defaultLocale)
|
||||
}
|
||||
|
||||
return validLocale
|
||||
})
|
||||
|
||||
const [timezone, setTimezoneState] = useState<string>(() => {
|
||||
const stored = localStorage.getItem(timezoneStorageKey)
|
||||
const supportedTimezones = getSupportedTimezones()
|
||||
const validTimezone = stored && supportedTimezones.includes(stored) ? stored : defaultTimezone
|
||||
|
||||
// Set default in localStorage if not present
|
||||
if (!stored) {
|
||||
localStorage.setItem(timezoneStorageKey, defaultTimezone)
|
||||
}
|
||||
|
||||
return validTimezone
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Set document language attribute for accessibility
|
||||
document.documentElement.lang = locale.split('-')[0]
|
||||
}, [locale])
|
||||
|
||||
const value = {
|
||||
locale,
|
||||
timezone,
|
||||
setLocale: (newLocale: Locale) => {
|
||||
localStorage.setItem(localeStorageKey, newLocale)
|
||||
setLocaleState(newLocale)
|
||||
},
|
||||
setTimezone: (newTimezone: string) => {
|
||||
localStorage.setItem(timezoneStorageKey, newTimezone)
|
||||
setTimezoneState(newTimezone)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<LocaleProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</LocaleProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,18 @@ export function SocketBadge() {
|
||||
const { isConnected, isReconnecting } = useSocket()
|
||||
|
||||
if (isReconnecting) {
|
||||
return <Badge variant="secondary" className="text-xs">Reconnecting</Badge>
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Reconnecting
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={isConnected ? 'default' : 'destructive'} className="text-xs">
|
||||
<Badge
|
||||
variant={isConnected ? 'default' : 'destructive'}
|
||||
className="text-xs"
|
||||
>
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Theme, ThemeProviderContext } from '@/contexts/ThemeContext'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { ThemeProviderContext, type Theme } from '@/contexts/ThemeContext'
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
|
||||
156
src/components/admin/UsersHeader.tsx
Normal file
156
src/components/admin/UsersHeader.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Filter, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react'
|
||||
|
||||
export type UserSortField = 'name' | 'email' | 'role' | 'credits' | 'created_at'
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
export type UserStatus = 'all' | 'active' | 'inactive'
|
||||
|
||||
interface UsersHeaderProps {
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
sortBy: UserSortField
|
||||
onSortByChange: (sortBy: UserSortField) => void
|
||||
sortOrder: SortOrder
|
||||
onSortOrderChange: (order: SortOrder) => void
|
||||
statusFilter: UserStatus
|
||||
onStatusFilterChange: (status: UserStatus) => void
|
||||
onRefresh: () => void
|
||||
loading: boolean
|
||||
error: string | null
|
||||
userCount: number
|
||||
}
|
||||
|
||||
export function UsersHeader({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
sortBy,
|
||||
onSortByChange,
|
||||
sortOrder,
|
||||
onSortOrderChange,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
onRefresh,
|
||||
loading,
|
||||
error,
|
||||
userCount,
|
||||
}: UsersHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">User Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage user accounts and permissions
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{!loading && !error && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{statusFilter !== 'all'
|
||||
? `${userCount} ${statusFilter} user${userCount !== 1 ? 's' : ''}`
|
||||
: `${userCount} user${userCount !== 1 ? 's' : ''}`
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={onRefresh} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</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 users..."
|
||||
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 UserSortField)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="role">Role</SelectItem>
|
||||
<SelectItem value="credits">Credits</SelectItem>
|
||||
<SelectItem value="created_at">Created Date</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>
|
||||
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={value => onStatusFilterChange(value as UserStatus)}
|
||||
>
|
||||
<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="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title="Refresh users"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
112
src/components/admin/UsersLoadingStates.tsx
Normal file
112
src/components/admin/UsersLoadingStates.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { AlertCircle, RefreshCw, Users } from 'lucide-react'
|
||||
|
||||
export function UsersLoading() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead>Credits</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UsersError({ error, onRetry }: { error: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 space-y-4">
|
||||
<div className="rounded-full p-3 bg-destructive/10">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-semibold">Failed to load users</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onRetry} variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UsersEmpty({ searchQuery, statusFilter }: {
|
||||
searchQuery: string;
|
||||
statusFilter: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 space-y-4">
|
||||
<div className="rounded-full p-3 bg-muted">
|
||||
<Users className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{searchQuery || statusFilter !== 'all'
|
||||
? 'No users found'
|
||||
: 'No users yet'
|
||||
}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{searchQuery
|
||||
? `No users match "${searchQuery}"`
|
||||
: statusFilter !== 'all'
|
||||
? `No ${statusFilter} users found`
|
||||
: 'Users will appear here once they are added to the system'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
src/components/admin/UsersTable.tsx
Normal file
88
src/components/admin/UsersTable.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import type { User } from '@/types/auth'
|
||||
import { Edit, UserCheck, UserX } from 'lucide-react'
|
||||
|
||||
interface UsersTableProps {
|
||||
users: User[]
|
||||
onEdit: (user: User) => void
|
||||
onToggleStatus: (user: User) => void
|
||||
}
|
||||
|
||||
export function UsersTable({ users, onEdit, onToggleStatus }: UsersTableProps) {
|
||||
const getRoleBadge = (role: string) => {
|
||||
return (
|
||||
<Badge variant={role === 'admin' ? 'destructive' : 'secondary'}>
|
||||
{role}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusBadge = (isActive: boolean) => {
|
||||
return (
|
||||
<Badge variant={isActive ? 'default' : 'secondary'}>
|
||||
{isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead>Credits</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map(user => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.name}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{getRoleBadge(user.role)}</TableCell>
|
||||
<TableCell>{user.plan.name}</TableCell>
|
||||
<TableCell>{user.credits.toLocaleString()}</TableCell>
|
||||
<TableCell>{getStatusBadge(user.is_active)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(user)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleStatus(user)}
|
||||
>
|
||||
{user.is_active ? (
|
||||
<UserX className="h-4 w-4" />
|
||||
) : (
|
||||
<UserCheck className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { OAuthButtons } from './OAuthButtons'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { useState } from 'react'
|
||||
import { OAuthButtons } from './OAuthButtons'
|
||||
|
||||
export function LoginForm() {
|
||||
const { login } = useAuth()
|
||||
@@ -44,7 +50,9 @@ export function LoginForm() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">Sign in</CardTitle>
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
Sign in
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Enter your email and password to sign in to your account
|
||||
</CardDescription>
|
||||
@@ -83,11 +91,7 @@ export function LoginForm() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { api } from '@/lib/api'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function OAuthButtons() {
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
@@ -92,7 +92,7 @@ export function OAuthButtons() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{providers.map((provider) => (
|
||||
{providers.map(provider => (
|
||||
<Button
|
||||
key={provider}
|
||||
variant="outline"
|
||||
@@ -109,8 +109,7 @@ export function OAuthButtons() {
|
||||
<span className="ml-2">
|
||||
{loading === provider
|
||||
? 'Connecting...'
|
||||
: `Continue with ${getProviderName(provider)}`
|
||||
}
|
||||
: `Continue with ${getProviderName(provider)}`}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { OAuthButtons } from './OAuthButtons'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { useState } from 'react'
|
||||
import { OAuthButtons } from './OAuthButtons'
|
||||
|
||||
export function RegisterForm() {
|
||||
const { register } = useAuth()
|
||||
@@ -62,7 +68,9 @@ export function RegisterForm() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">Create account</CardTitle>
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
Create account
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Enter your information to create your account
|
||||
</CardDescription>
|
||||
@@ -128,11 +136,7 @@ export function RegisterForm() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
30
src/components/dashboard/DashboardHeader.tsx
Normal file
30
src/components/dashboard/DashboardHeader.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
onRefresh: () => void
|
||||
isRefreshing: boolean
|
||||
}
|
||||
|
||||
export function DashboardHeader({ onRefresh, isRefreshing }: DashboardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Overview of your soundboard and track statistics
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
src/components/dashboard/DashboardLoadingStates.tsx
Normal file
74
src/components/dashboard/DashboardLoadingStates.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { DashboardHeader } from '@/components/dashboard/DashboardHeader'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
export function LoadingSkeleton() {
|
||||
return (
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [{ label: 'Dashboard' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
<DashboardHeader onRefresh={() => {}} isRefreshing={false} />
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
|
||||
Soundboard Statistics
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Loading...
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold animate-pulse">---</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
|
||||
Track Statistics
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i + 4}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Loading...
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold animate-pulse">---</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorState({ error, onRetry }: { error: string; onRetry: () => void }) {
|
||||
return (
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [{ label: 'Dashboard' }],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
<DashboardHeader onRefresh={onRetry} isRefreshing={false} />
|
||||
<div className="border-2 border-dashed border-destructive/25 rounded-lg p-4">
|
||||
<p className="text-destructive">Error loading statistics: {error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
25
src/components/dashboard/StatisticCard.tsx
Normal file
25
src/components/dashboard/StatisticCard.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface StatisticCardProps {
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
value: ReactNode
|
||||
description: string
|
||||
}
|
||||
|
||||
export function StatisticCard({ title, icon: Icon, value, description }: StatisticCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-0">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-center">{value}</div>
|
||||
<p className="text-xs text-muted-foreground text-center mt-1">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
164
src/components/dashboard/StatisticsGrid.tsx
Normal file
164
src/components/dashboard/StatisticsGrid.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
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, MessageSquare } from 'lucide-react'
|
||||
|
||||
interface SoundboardStatistics {
|
||||
sound_count: number
|
||||
total_play_count: number
|
||||
total_duration: number
|
||||
total_size: number
|
||||
}
|
||||
|
||||
interface TrackStatistics {
|
||||
track_count: number
|
||||
total_play_count: number
|
||||
total_duration: number
|
||||
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, ttsStatistics }: StatisticsGridProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
|
||||
Soundboard Statistics
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatisticCard
|
||||
title="Total Sounds"
|
||||
icon={Volume2}
|
||||
value={<NumberFlow value={soundboardStatistics.sound_count} />}
|
||||
description="Soundboard audio files"
|
||||
/>
|
||||
<StatisticCard
|
||||
title="Total Plays"
|
||||
icon={Play}
|
||||
value={<NumberFlow value={soundboardStatistics.total_play_count} />}
|
||||
description="All-time play count"
|
||||
/>
|
||||
<StatisticCard
|
||||
title="Total Duration"
|
||||
icon={Clock}
|
||||
value={
|
||||
<NumberFlowDuration
|
||||
duration={soundboardStatistics.total_duration}
|
||||
variant="wordy"
|
||||
/>
|
||||
}
|
||||
description="Combined audio duration"
|
||||
/>
|
||||
<StatisticCard
|
||||
title="Total Size"
|
||||
icon={HardDrive}
|
||||
value={
|
||||
<NumberFlowSize
|
||||
size={soundboardStatistics.total_size}
|
||||
binary={true}
|
||||
/>
|
||||
}
|
||||
description="Original + normalized files"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
|
||||
Track Statistics
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatisticCard
|
||||
title="Total Tracks"
|
||||
icon={Music}
|
||||
value={<NumberFlow value={trackStatistics.track_count} />}
|
||||
description="Extracted audio tracks"
|
||||
/>
|
||||
<StatisticCard
|
||||
title="Total Plays"
|
||||
icon={Play}
|
||||
value={<NumberFlow value={trackStatistics.total_play_count} />}
|
||||
description="All-time play count"
|
||||
/>
|
||||
<StatisticCard
|
||||
title="Total Duration"
|
||||
icon={Clock}
|
||||
value={
|
||||
<NumberFlowDuration
|
||||
duration={trackStatistics.total_duration}
|
||||
variant="wordy"
|
||||
/>
|
||||
}
|
||||
description="Combined track duration"
|
||||
/>
|
||||
<StatisticCard
|
||||
title="Total Size"
|
||||
icon={HardDrive}
|
||||
value={
|
||||
<NumberFlowSize
|
||||
size={trackStatistics.total_size}
|
||||
binary={true}
|
||||
/>
|
||||
}
|
||||
description="Original + normalized files"
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
156
src/components/dashboard/TopSoundsSection.tsx
Normal file
156
src/components/dashboard/TopSoundsSection.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { NumberFlowDuration } from '@/components/ui/number-flow-duration'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { Clock, Loader2, Music, Trophy } from 'lucide-react'
|
||||
|
||||
interface TopSound {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
play_count: number
|
||||
duration: number | null
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
interface TopSoundsSectionProps {
|
||||
topSounds: TopSound[]
|
||||
loading: boolean
|
||||
soundType: string
|
||||
period: string
|
||||
limit: number
|
||||
onSoundTypeChange: (value: string) => void
|
||||
onPeriodChange: (value: string) => void
|
||||
onLimitChange: (value: number) => void
|
||||
}
|
||||
|
||||
export function TopSoundsSection({
|
||||
topSounds,
|
||||
loading,
|
||||
soundType,
|
||||
period,
|
||||
limit,
|
||||
onSoundTypeChange,
|
||||
onPeriodChange,
|
||||
onLimitChange,
|
||||
}: TopSoundsSectionProps) {
|
||||
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 Sounds</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Type:</span>
|
||||
<Select value={soundType} onValueChange={onSoundTypeChange}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="SDB">Soundboard</SelectItem>
|
||||
<SelectItem value="EXT">Tracks</SelectItem>
|
||||
<SelectItem value="TTS">TTS</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 sounds...
|
||||
</div>
|
||||
) : topSounds.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Music className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No sounds found for the selected criteria</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{topSounds.map((sound, index) => (
|
||||
<div
|
||||
key={sound.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>
|
||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{sound.name}</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground mt-1">
|
||||
{sound.duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<NumberFlowDuration
|
||||
duration={sound.duration}
|
||||
variant="clock"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className="px-1.5 py-0.5 bg-secondary rounded text-xs">
|
||||
{sound.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
<NumberFlow value={sound.play_count} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">plays</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
82
src/components/extractions/CreateExtractionDialog.tsx
Normal file
82
src/components/extractions/CreateExtractionDialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface CreateExtractionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
loading: boolean
|
||||
url: string
|
||||
onUrlChange: (url: string) => void
|
||||
onSubmit: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function CreateExtractionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
loading,
|
||||
url,
|
||||
onUrlChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: CreateExtractionDialogProps) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !loading) {
|
||||
onSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Extraction</DialogTitle>
|
||||
<DialogDescription>
|
||||
Extract audio from YouTube, SoundCloud, Vimeo, TikTok, Twitter,
|
||||
Instagram, and many other platforms.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="url">URL</Label>
|
||||
<Input
|
||||
id="url"
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
value={url}
|
||||
onChange={e => onUrlChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Paste a link to extract audio from the media
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onSubmit} disabled={loading || !url.trim()}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Extraction'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
157
src/components/extractions/ExtractionsHeader.tsx
Normal file
157
src/components/extractions/ExtractionsHeader.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ExtractionSortField, ExtractionSortOrder, ExtractionStatus } from '@/lib/api/services/extractions'
|
||||
import { Filter, Plus, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react'
|
||||
|
||||
interface ExtractionsHeaderProps {
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
sortBy: ExtractionSortField
|
||||
onSortByChange: (sortBy: ExtractionSortField) => void
|
||||
sortOrder: ExtractionSortOrder
|
||||
onSortOrderChange: (order: ExtractionSortOrder) => void
|
||||
statusFilter: ExtractionStatus | 'all'
|
||||
onStatusFilterChange: (status: ExtractionStatus | 'all') => void
|
||||
onRefresh: () => void
|
||||
onCreateClick: () => void
|
||||
loading: boolean
|
||||
error: string | null
|
||||
extractionCount: number
|
||||
}
|
||||
|
||||
export function ExtractionsHeader({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
sortBy,
|
||||
onSortByChange,
|
||||
sortOrder,
|
||||
onSortOrderChange,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
onRefresh,
|
||||
onCreateClick,
|
||||
loading,
|
||||
error,
|
||||
extractionCount,
|
||||
}: ExtractionsHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Audio Extractions</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Extract audio from YouTube, SoundCloud, and other platforms
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{!loading && !error && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{statusFilter !== 'all'
|
||||
? `${extractionCount} ${statusFilter} extraction${extractionCount !== 1 ? 's' : ''}`
|
||||
: `${extractionCount} extraction${extractionCount !== 1 ? 's' : ''}`
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={onCreateClick}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Extraction
|
||||
</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 extractions..."
|
||||
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={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-[150px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="title">Title</SelectItem>
|
||||
<SelectItem value="status">Status</SelectItem>
|
||||
<SelectItem value="service">Service</SelectItem>
|
||||
<SelectItem value="created_at">Created Date</SelectItem>
|
||||
<SelectItem value="updated_at">Updated Date</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 extractions"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
67
src/components/extractions/ExtractionsLoadingStates.tsx
Normal file
67
src/components/extractions/ExtractionsLoadingStates.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AlertCircle, Download } from 'lucide-react'
|
||||
import type { ExtractionStatus } from '@/lib/api/services/extractions'
|
||||
|
||||
interface ExtractionsLoadingProps {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function ExtractionsLoading({ count = 5 }: ExtractionsLoadingProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ExtractionsErrorProps {
|
||||
error: string
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function ExtractionsError({ error, onRetry }: ExtractionsErrorProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Failed to load extractions</h3>
|
||||
<p className="text-muted-foreground mb-4">{error}</p>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ExtractionsEmptyProps {
|
||||
searchQuery: string
|
||||
statusFilter?: ExtractionStatus | 'all'
|
||||
}
|
||||
|
||||
export function ExtractionsEmpty({ searchQuery, statusFilter = 'all' }: ExtractionsEmptyProps) {
|
||||
const isFiltered = searchQuery || statusFilter !== 'all'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<Download className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{isFiltered ? 'No extractions found' : 'No extractions yet'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{isFiltered
|
||||
? statusFilter !== 'all'
|
||||
? `No ${statusFilter} extractions match your search criteria.`
|
||||
: 'No extractions match your search criteria.'
|
||||
: 'Start by adding a URL to extract audio from your favorite platforms'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
231
src/components/extractions/ExtractionsRow.tsx
Normal file
231
src/components/extractions/ExtractionsRow.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { TableCell, TableRow } from '@/components/ui/table'
|
||||
import { extractionsService, type ExtractionInfo } from '@/lib/api/services/extractions'
|
||||
import { formatDateDistanceToNow } from '@/utils/format-date'
|
||||
import {
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Trash2,
|
||||
User
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface ExtractionsRowProps {
|
||||
extraction: ExtractionInfo
|
||||
onExtractionDeleted?: (extractionId: number) => void
|
||||
}
|
||||
|
||||
export function ExtractionsRow({ extraction, onExtractionDeleted }: ExtractionsRowProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
|
||||
const handleDeleteExtraction = async () => {
|
||||
if (!extraction.id) return
|
||||
|
||||
try {
|
||||
setDeleteLoading(true)
|
||||
const response = await extractionsService.deleteExtraction(extraction.id)
|
||||
toast.success(response.message)
|
||||
|
||||
// Close dialog
|
||||
setShowDeleteDialog(false)
|
||||
|
||||
// Notify parent component
|
||||
if (onExtractionDeleted) {
|
||||
onExtractionDeleted(extraction.id)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to delete extraction'
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
const getStatusBadge = (status: ExtractionInfo['status']) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Pending
|
||||
</Badge>
|
||||
)
|
||||
case 'processing':
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Processing
|
||||
</Badge>
|
||||
)
|
||||
case 'completed':
|
||||
return (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Completed
|
||||
</Badge>
|
||||
)
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Failed
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getServiceBadge = (service: string | undefined) => {
|
||||
if (!service) return <span className="text-muted-foreground">-</span>
|
||||
|
||||
const serviceColors: Record<string, string> = {
|
||||
youtube: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
|
||||
soundcloud: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
|
||||
vimeo: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||
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 =
|
||||
serviceColors[service.toLowerCase()] ||
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={colorClass}>
|
||||
{service.toUpperCase()}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate max-w-80">
|
||||
{extraction.title || 'Processing...'}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{extraction.url}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{getServiceBadge(extraction.service)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
{extraction.user_name || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
{getStatusBadge(extraction.status)}
|
||||
{extraction.error && (
|
||||
<div
|
||||
className="text-xs text-destructive max-w-48 truncate"
|
||||
title={extraction.error}
|
||||
>
|
||||
{extraction.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDateDistanceToNow(extraction.created_at)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a
|
||||
href={extraction.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open original URL"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
title="Delete extraction"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Extraction</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this extraction? This action cannot be undone.
|
||||
{extraction.title && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
<strong>Title:</strong> {extraction.title}
|
||||
<br />
|
||||
<strong>URL:</strong> {extraction.url}
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
disabled={deleteLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteExtraction}
|
||||
disabled={deleteLoading}
|
||||
>
|
||||
{deleteLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
42
src/components/extractions/ExtractionsTable.tsx
Normal file
42
src/components/extractions/ExtractionsTable.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ExtractionsRow } from '@/components/extractions/ExtractionsRow'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import type { ExtractionInfo } from '@/lib/api/services/extractions'
|
||||
|
||||
interface ExtractionsTableProps {
|
||||
extractions: ExtractionInfo[]
|
||||
onExtractionDeleted?: (extractionId: number) => void
|
||||
}
|
||||
|
||||
export function ExtractionsTable({ extractions, onExtractionDeleted }: ExtractionsTableProps) {
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead className="text-center">Service</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-center">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{extractions.map(extraction => (
|
||||
<ExtractionsRow
|
||||
key={extraction.id}
|
||||
extraction={extraction}
|
||||
onExtractionDeleted={onExtractionDeleted}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
src/components/nav/CreditsNav.tsx
Normal file
62
src/components/nav/CreditsNav.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { USER_EVENTS, userEvents } from '@/lib/events'
|
||||
import type { User } from '@/types/auth'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { CircleDollarSign } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '../ui/sidebar'
|
||||
|
||||
interface CreditsNavProps {
|
||||
user: User
|
||||
}
|
||||
|
||||
export function CreditsNav({ user }: CreditsNavProps) {
|
||||
const [credits, setCredits] = useState(user.credits)
|
||||
|
||||
useEffect(() => {
|
||||
const handleCreditsChanged = (...args: unknown[]) => {
|
||||
const data = args[0] as { credits_after: number }
|
||||
setCredits(data.credits_after)
|
||||
}
|
||||
|
||||
userEvents.on(USER_EVENTS.USER_CREDITS_CHANGED, handleCreditsChanged)
|
||||
|
||||
return () => {
|
||||
userEvents.off(USER_EVENTS.USER_CREDITS_CHANGED, handleCreditsChanged)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update credits when user prop changes (initial load or refresh)
|
||||
useEffect(() => {
|
||||
setCredits(user.credits)
|
||||
}, [user.credits])
|
||||
|
||||
const tooltipText = `Credits: ${credits} / ${user.plan.max_credits}`
|
||||
|
||||
// Determine icon color based on credit levels
|
||||
const getIconColor = () => {
|
||||
if (credits === 0) return 'text-red-500'
|
||||
if (credits <= user.plan.max_credits * 0.2) return 'text-yellow-500'
|
||||
return 'text-green-500'
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="cursor-default group-data-[collapsible=icon]:justify-center"
|
||||
tooltip={tooltipText}
|
||||
>
|
||||
<CircleDollarSign className={`h-5 w-5 ${getIconColor()}`} />
|
||||
<div className="flex flex-1 items-center justify-between text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="font-semibold">Credits:</span>
|
||||
<span className="text-muted-foreground">
|
||||
<NumberFlow value={credits} /> /{' '}
|
||||
<NumberFlow value={user.plan.max_credits} />
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -15,9 +15,7 @@ export function NavGroup({ label, children }: NavGroupProps) {
|
||||
<SidebarGroup>
|
||||
{label && <SidebarGroupLabel>{label}</SidebarGroupLabel>}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{children}
|
||||
</SidebarMenu>
|
||||
<SidebarMenu>{children}</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
interface NavItemProps {
|
||||
href: string
|
||||
|
||||
44
src/components/nav/StopSoundsButton.tsx
Normal file
44
src/components/nav/StopSoundsButton.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react'
|
||||
import { Square } from 'lucide-react'
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'
|
||||
import { soundsService } from '@/lib/api/services/sounds'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function StopSoundsButton() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleStopSounds = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await soundsService.stopSounds()
|
||||
toast.success('All sounds stopped')
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to stop sounds: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const tooltipText = isLoading ? 'Stopping sounds...' : 'Stop All Sounds'
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
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 cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<Square className="h-5 w-5 fill-current" />
|
||||
<span className="font-semibold group-data-[collapsible=icon]:hidden">
|
||||
{isLoading ? 'Stopping...' : 'Stop All Sounds'}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronsUpDown, LogOut, UserIcon } from 'lucide-react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -8,10 +8,15 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '../ui/sidebar'
|
||||
import type { User } from '@/types/auth'
|
||||
import { ChevronsUpDown, LogOut, UserIcon } from 'lucide-react'
|
||||
import { Link } from 'react-router'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '../ui/sidebar'
|
||||
|
||||
interface UserNavProps {
|
||||
user: User
|
||||
|
||||
147
src/components/player/CompactPlayer.tsx
Normal file
147
src/components/player/CompactPlayer.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
type MessageResponse,
|
||||
type PlayerState,
|
||||
playerService,
|
||||
} from '@/lib/api/services/player'
|
||||
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { CompactPlayerControls } from './CompactPlayerControls'
|
||||
import { CompactPlayerProgress } from './CompactPlayerProgress'
|
||||
import { CompactPlayerTrackInfo } from './CompactPlayerTrackInfo'
|
||||
|
||||
interface CompactPlayerProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
const [state, setState] = useState<PlayerState>({
|
||||
status: 'stopped',
|
||||
mode: 'continuous',
|
||||
volume: 80,
|
||||
previous_volume: 80,
|
||||
position: 0,
|
||||
play_next_queue: [],
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// Load initial state
|
||||
useEffect(() => {
|
||||
const loadState = async () => {
|
||||
try {
|
||||
const initialState = await playerService.getState()
|
||||
setState(initialState)
|
||||
} catch (error) {
|
||||
console.error('Failed to load player state:', error)
|
||||
}
|
||||
}
|
||||
loadState()
|
||||
}, [])
|
||||
|
||||
// Listen for player state updates
|
||||
useEffect(() => {
|
||||
const handlePlayerState = (...args: unknown[]) => {
|
||||
const newState = args[0] as PlayerState
|
||||
setState(newState)
|
||||
}
|
||||
|
||||
playerEvents.on(PLAYER_EVENTS.PLAYER_STATE, handlePlayerState)
|
||||
|
||||
return () => {
|
||||
playerEvents.off(PLAYER_EVENTS.PLAYER_STATE, handlePlayerState)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const executeAction = useCallback(
|
||||
async (
|
||||
action: () => Promise<void | MessageResponse>,
|
||||
actionName: string,
|
||||
) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${actionName}:`, error)
|
||||
toast.error(`Failed to ${actionName}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (state.status === 'playing') {
|
||||
executeAction(playerService.pause, 'pause')
|
||||
} else {
|
||||
executeAction(playerService.play, 'play')
|
||||
}
|
||||
}, [state.status, executeAction])
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
executeAction(playerService.previous, 'go to previous track')
|
||||
}, [executeAction])
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
executeAction(playerService.next, 'go to next track')
|
||||
}, [executeAction])
|
||||
|
||||
const handleVolumeToggle = useCallback(() => {
|
||||
if (state.volume === 0) {
|
||||
// Unmute
|
||||
executeAction(playerService.unmute, 'unmute')
|
||||
} else {
|
||||
// Mute
|
||||
executeAction(playerService.mute, 'mute')
|
||||
}
|
||||
}, [state.volume, executeAction])
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
const expandFn = (
|
||||
window as unknown as { __expandPlayerFromSidebar?: () => void }
|
||||
).__expandPlayerFromSidebar
|
||||
if (expandFn) expandFn()
|
||||
}, [])
|
||||
|
||||
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"
|
||||
/>
|
||||
|
||||
{/* Expanded state - full player */}
|
||||
<div className="group-data-[collapsible=icon]:hidden">
|
||||
<CompactPlayerTrackInfo
|
||||
currentSound={state.current_sound}
|
||||
playlistName={state.playlist?.name}
|
||||
onExpand={handleExpand}
|
||||
/>
|
||||
|
||||
<CompactPlayerProgress
|
||||
position={state.position}
|
||||
duration={state.duration || 0}
|
||||
/>
|
||||
|
||||
<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,57 +1,119 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
Shuffle,
|
||||
List,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
Music,
|
||||
ExternalLink,
|
||||
Download,
|
||||
MoreVertical,
|
||||
ArrowRight,
|
||||
ArrowRightToLine
|
||||
} from 'lucide-react'
|
||||
import { playerService, type PlayerState, type PlayerMode, type MessageResponse } from '@/lib/api/services/player'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { filesService } from '@/lib/api/services/files'
|
||||
import { playerEvents, PLAYER_EVENTS } from '@/lib/events'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
type MessageResponse,
|
||||
type PlayerMode,
|
||||
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 {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Square,
|
||||
} from 'lucide-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'
|
||||
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
|
||||
}
|
||||
|
||||
export function Player({ className }: PlayerProps) {
|
||||
export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
||||
const [state, setState] = useState<PlayerState>({
|
||||
status: 'stopped',
|
||||
mode: 'continuous',
|
||||
volume: 80,
|
||||
position: 0
|
||||
previous_volume: 80,
|
||||
position: 0,
|
||||
play_next_queue: [],
|
||||
})
|
||||
const [displayMode, setDisplayMode] = useState<PlayerDisplayMode>('normal')
|
||||
const [displayMode, setDisplayMode] = useState<PlayerDisplayMode>(() => {
|
||||
// Initialize from localStorage or default to 'normal'
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem(
|
||||
'playerDisplayMode',
|
||||
) as PlayerDisplayMode
|
||||
return saved &&
|
||||
['normal', 'minimized', 'maximized', 'sidebar'].includes(saved)
|
||||
? saved
|
||||
: 'normal'
|
||||
}
|
||||
return 'normal'
|
||||
})
|
||||
|
||||
// Notify parent when display mode changes and save to localStorage
|
||||
useEffect(() => {
|
||||
onPlayerModeChange?.(displayMode)
|
||||
// Save to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('playerDisplayMode', displayMode)
|
||||
}
|
||||
}, [displayMode, onPlayerModeChange])
|
||||
const [showPlaylist, setShowPlaylist] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [previousVolume, setPreviousVolume] = useState(50)
|
||||
|
||||
|
||||
// Load initial state
|
||||
useEffect(() => {
|
||||
@@ -66,11 +128,19 @@ export function Player({ className }: PlayerProps) {
|
||||
loadState()
|
||||
}, [])
|
||||
|
||||
// Listen for player state updates
|
||||
// Listen for player state updates with optimization
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
|
||||
useEffect(() => {
|
||||
const handlePlayerState = (newState: PlayerState) => {
|
||||
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)
|
||||
|
||||
@@ -95,7 +165,11 @@ export function Player({ className }: PlayerProps) {
|
||||
}
|
||||
}, [displayMode])
|
||||
|
||||
const executeAction = useCallback(async (action: () => Promise<void | MessageResponse>, actionName: string) => {
|
||||
const executeAction = useCallback(
|
||||
async (
|
||||
action: () => Promise<void | MessageResponse>,
|
||||
actionName: string,
|
||||
) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await action()
|
||||
@@ -105,7 +179,9 @@ export function Player({ className }: PlayerProps) {
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (state.status === 'playing') {
|
||||
@@ -127,34 +203,40 @@ export function Player({ className }: PlayerProps) {
|
||||
executeAction(playerService.next, 'go to next track')
|
||||
}, [executeAction])
|
||||
|
||||
const handleSeek = useCallback((position: number[]) => {
|
||||
const handleSeek = useCallback(
|
||||
(position: number[]) => {
|
||||
const newPosition = position[0]
|
||||
executeAction(() => playerService.seek(newPosition), 'seek')
|
||||
}, [executeAction])
|
||||
},
|
||||
[executeAction],
|
||||
)
|
||||
|
||||
const handleVolumeChange = useCallback((volume: number[]) => {
|
||||
const handleVolumeChange = useCallback(
|
||||
(volume: number[]) => {
|
||||
const newVolume = volume[0]
|
||||
executeAction(() => playerService.setVolume(newVolume), 'change volume')
|
||||
if (newVolume > 0 && isMuted) {
|
||||
setIsMuted(false)
|
||||
}
|
||||
}, [executeAction, isMuted])
|
||||
},
|
||||
[executeAction],
|
||||
)
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
if (isMuted) {
|
||||
if (state.volume === 0) {
|
||||
// Unmute
|
||||
executeAction(() => playerService.setVolume(previousVolume), 'unmute')
|
||||
setIsMuted(false)
|
||||
executeAction(playerService.unmute, 'unmute')
|
||||
} else {
|
||||
// Mute
|
||||
setPreviousVolume(state.volume)
|
||||
executeAction(() => playerService.setVolume(0), 'mute')
|
||||
setIsMuted(true)
|
||||
executeAction(playerService.mute, 'mute')
|
||||
}
|
||||
}, [isMuted, previousVolume, state.volume, executeAction])
|
||||
}, [state.volume, executeAction])
|
||||
|
||||
const handleModeChange = useCallback(() => {
|
||||
const modes: PlayerMode[] = ['continuous', 'loop', 'loop_one', 'random', 'single']
|
||||
const modes: PlayerMode[] = [
|
||||
'continuous',
|
||||
'loop',
|
||||
'loop_one',
|
||||
'random',
|
||||
'single',
|
||||
]
|
||||
const currentIndex = modes.indexOf(state.mode)
|
||||
const nextMode = modes[(currentIndex + 1) % modes.length]
|
||||
executeAction(() => playerService.setMode(nextMode), 'change mode')
|
||||
@@ -172,20 +254,20 @@ export function Player({ className }: 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')
|
||||
}, [])
|
||||
|
||||
const getPlayerPosition = () => {
|
||||
switch (displayMode) {
|
||||
@@ -193,6 +275,8 @@ export function Player({ className }: PlayerProps) {
|
||||
return 'fixed bottom-4 right-4 z-50'
|
||||
case 'maximized':
|
||||
return 'fixed inset-0 z-50 bg-background'
|
||||
case 'sidebar':
|
||||
return 'hidden' // Hidden when in sidebar mode
|
||||
default:
|
||||
return 'fixed bottom-4 right-4 z-50'
|
||||
}
|
||||
@@ -202,46 +286,17 @@ export function Player({ className }: 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"
|
||||
@@ -263,9 +318,9 @@ export function Player({ className }: PlayerProps) {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDisplayMode('minimized')}
|
||||
onClick={() => setDisplayMode('sidebar')}
|
||||
className="h-6 w-6 p-0 hover:bg-muted"
|
||||
title="Minimize"
|
||||
title="Minimize to Sidebar"
|
||||
>
|
||||
<Minimize2 className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -280,180 +335,32 @@ export function Player({ className }: 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"
|
||||
>
|
||||
{isMuted || state.volume === 0 ? (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
) : (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="w-16">
|
||||
<Slider
|
||||
value={[isMuted ? 0 : state.volume]}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={handleVolumeChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playlist */}
|
||||
{showPlaylist && state.playlist && (
|
||||
@@ -461,8 +368,20 @@ export function Player({ className }: PlayerProps) {
|
||||
<Playlist
|
||||
playlist={state.playlist}
|
||||
currentIndex={state.index}
|
||||
onTrackSelect={(index) => executeAction(() => playerService.playAtIndex(index), 'play track')}
|
||||
onTrackSelect={index =>
|
||||
executeAction(
|
||||
() => playerService.playAtIndex(index),
|
||||
'play track',
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Play Next Queue */}
|
||||
{state.play_next_queue.length > 0 && (
|
||||
<div className="mt-2 border-t">
|
||||
<PlayNextQueue queue={state.play_next_queue} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -474,6 +393,16 @@ export function Player({ className }: 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"
|
||||
@@ -483,198 +412,68 @@ export function Player({ className }: 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="w-80 aspect-square 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>
|
||||
{state.playlist && (
|
||||
<p className="text-lg text-muted-foreground">
|
||||
{state.playlist.name}
|
||||
</p>
|
||||
)}
|
||||
</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}
|
||||
>
|
||||
{isMuted || state.volume === 0 ? (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
) : (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="w-24">
|
||||
<Slider
|
||||
value={[isMuted ? 0 : state.volume]}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={handleVolumeChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground w-8">
|
||||
{Math.round(isMuted ? 0 : 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}
|
||||
onTrackSelect={(index) => executeAction(() => playerService.playAtIndex(index), 'play track')}
|
||||
onTrackSelect={index =>
|
||||
executeAction(
|
||||
() => playerService.playAtIndex(index),
|
||||
'play track',
|
||||
)
|
||||
}
|
||||
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>
|
||||
)}
|
||||
@@ -682,6 +481,18 @@ export function Player({ className }: PlayerProps) {
|
||||
</div>
|
||||
)
|
||||
|
||||
// Expose expand function for external use
|
||||
useEffect(() => {
|
||||
// Store expand function globally so sidebar can access it
|
||||
const windowWithExpand = window as unknown as {
|
||||
__expandPlayerFromSidebar?: () => void
|
||||
}
|
||||
windowWithExpand.__expandPlayerFromSidebar = expandFromSidebar
|
||||
return () => {
|
||||
delete windowWithExpand.__expandPlayerFromSidebar
|
||||
}
|
||||
}, [expandFromSidebar])
|
||||
|
||||
if (!state) return null
|
||||
|
||||
return (
|
||||
|
||||
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 { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Music, Play } from 'lucide-react'
|
||||
import { type PlayerPlaylist } from '@/lib/api/services/player'
|
||||
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, playerService } from '@/lib/api/services/player'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import { Music, Play, Search, X, ListPlus } from 'lucide-react'
|
||||
|
||||
interface PlaylistProps {
|
||||
playlist: PlayerPlaylist
|
||||
@@ -17,47 +26,88 @@ export function Playlist({
|
||||
playlist,
|
||||
currentIndex,
|
||||
onTrackSelect,
|
||||
variant = 'normal'
|
||||
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 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-sm truncate">
|
||||
{playlist.name}
|
||||
</h4>
|
||||
<h4 className="font-medium text-sm truncate">{playlist.name}</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{playlist.sounds.length} tracks
|
||||
</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'}>
|
||||
<ScrollArea
|
||||
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>
|
||||
|
||||
{/* Thumbnail - 1 column */}
|
||||
<div className="col-span-1">
|
||||
<div className={cn(
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted rounded flex items-center justify-center overflow-hidden',
|
||||
variant === 'maximized' ? 'w-6 h-6' : 'w-5 h-5'
|
||||
)}>
|
||||
variant === 'maximized' ? 'w-6 h-6' : 'w-5 h-5',
|
||||
)}
|
||||
>
|
||||
{sound.thumbnail ? (
|
||||
<img
|
||||
src={filesService.getThumbnailUrl(sound.id)}
|
||||
@@ -72,11 +122,13 @@ export function Playlist({
|
||||
|
||||
{/* Track name - 6 columns (takes most space) */}
|
||||
<div className="col-span-6">
|
||||
<span className={cn(
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
</div>
|
||||
@@ -88,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>
|
||||
|
||||
|
||||
105
src/components/playlists/CreatePlaylistDialog.tsx
Normal file
105
src/components/playlists/CreatePlaylistDialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
interface CreatePlaylistDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
loading: boolean
|
||||
name: string
|
||||
description: string
|
||||
genre: string
|
||||
onNameChange: (name: string) => void
|
||||
onDescriptionChange: (description: string) => void
|
||||
onGenreChange: (genre: string) => void
|
||||
onSubmit: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function CreatePlaylistDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
loading,
|
||||
name,
|
||||
description,
|
||||
genre,
|
||||
onNameChange,
|
||||
onDescriptionChange,
|
||||
onGenreChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: CreatePlaylistDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Playlist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new playlist to organize your sounds. Give it a name
|
||||
and optionally add a description and genre.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="My awesome playlist"
|
||||
value={name}
|
||||
onChange={e => onNameChange(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="A collection of my favorite sounds..."
|
||||
value={description}
|
||||
onChange={e => onDescriptionChange(e.target.value)}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="genre">Genre</Label>
|
||||
<Input
|
||||
id="genre"
|
||||
placeholder="Electronic, Rock, Comedy, etc."
|
||||
value={genre}
|
||||
onChange={e => onGenreChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={loading || !name.trim()}
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Playlist'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
140
src/components/playlists/PlaylistRow.tsx
Normal file
140
src/components/playlists/PlaylistRow.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { TableCell, TableRow } from '@/components/ui/table'
|
||||
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, 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, onDelete }: PlaylistRowProps) {
|
||||
const handleFavoriteToggle = () => {
|
||||
if (onFavoriteToggle) {
|
||||
onFavoriteToggle(playlist.id, !playlist.is_favorited)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<TableRow className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{playlist.name}</div>
|
||||
{playlist.description && (
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{playlist.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{playlist.genre ? (
|
||||
<Badge variant="secondary">{playlist.genre}</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{playlist.user_name ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{playlist.user_name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">System</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Music className="h-3 w-3 text-muted-foreground" />
|
||||
{playlist.sound_count}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
{formatDuration(playlist.total_duration || 0)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3 text-muted-foreground" />
|
||||
{formatDateDistanceToNow(playlist.created_at)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{playlist.is_current && <Badge variant="default">Current</Badge>}
|
||||
{playlist.is_main && <Badge variant="outline">Main</Badge>}
|
||||
{!playlist.is_current && !playlist.is_main && (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{onFavoriteToggle && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleFavoriteToggle}
|
||||
className="h-8 w-8 p-0"
|
||||
title={playlist.is_favorited ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
<Heart
|
||||
className={cn(
|
||||
'h-4 w-4 transition-all duration-200',
|
||||
playlist.is_favorited
|
||||
? 'fill-current text-red-500 hover:text-red-600'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onEdit(playlist)}
|
||||
className="h-8 w-8 p-0"
|
||||
title={`Edit "${playlist.name}"`}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{!playlist.is_current && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onSetCurrent(playlist)}
|
||||
className="h-8 w-8 p-0"
|
||||
title={`Set "${playlist.name}" as current playlist`}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
50
src/components/playlists/PlaylistTable.tsx
Normal file
50
src/components/playlists/PlaylistTable.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { PlaylistRow } from '@/components/playlists/PlaylistRow'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import type { Playlist } from '@/lib/api/services/playlists'
|
||||
|
||||
interface PlaylistTableProps {
|
||||
playlists: Playlist[]
|
||||
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, onDelete }: PlaylistTableProps) {
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="text-center">Genre</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Tracks</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
<TableHead className="text-center">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{playlists.map(playlist => (
|
||||
<PlaylistRow
|
||||
key={playlist.id}
|
||||
playlist={playlist}
|
||||
onEdit={onEdit}
|
||||
onSetCurrent={onSetCurrent}
|
||||
onFavoriteToggle={onFavoriteToggle}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
150
src/components/playlists/PlaylistsHeader.tsx
Normal file
150
src/components/playlists/PlaylistsHeader.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { PlaylistSortField, SortOrder } from '@/lib/api/services/playlists'
|
||||
import { Heart, Plus, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react'
|
||||
|
||||
interface PlaylistsHeaderProps {
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
sortBy: PlaylistSortField
|
||||
onSortByChange: (sortBy: PlaylistSortField) => void
|
||||
sortOrder: SortOrder
|
||||
onSortOrderChange: (order: SortOrder) => void
|
||||
onRefresh: () => void
|
||||
onCreateClick: () => void
|
||||
loading: boolean
|
||||
error: string | null
|
||||
playlistCount: number
|
||||
showFavoritesOnly: boolean
|
||||
onFavoritesToggle: (show: boolean) => void
|
||||
}
|
||||
|
||||
export function PlaylistsHeader({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
sortBy,
|
||||
onSortByChange,
|
||||
sortOrder,
|
||||
onSortOrderChange,
|
||||
onRefresh,
|
||||
onCreateClick,
|
||||
loading,
|
||||
error,
|
||||
playlistCount,
|
||||
showFavoritesOnly,
|
||||
onFavoritesToggle,
|
||||
}: PlaylistsHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Playlists</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage and browse your soundboard playlists
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{!loading && !error && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{showFavoritesOnly
|
||||
? `${playlistCount} favorite playlist${playlistCount !== 1 ? 's' : ''}`
|
||||
: `${playlistCount} playlist${playlistCount !== 1 ? 's' : ''}`
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={onCreateClick}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Playlist
|
||||
</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 playlists..."
|
||||
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 PlaylistSortField)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="genre">Genre</SelectItem>
|
||||
<SelectItem value="sound_count">Track Count</SelectItem>
|
||||
<SelectItem value="total_duration">Duration</SelectItem>
|
||||
<SelectItem value="created_at">Created Date</SelectItem>
|
||||
<SelectItem value="updated_at">Updated Date</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={showFavoritesOnly ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => onFavoritesToggle(!showFavoritesOnly)}
|
||||
title={showFavoritesOnly ? "Show all playlists" : "Show only favorites"}
|
||||
>
|
||||
<Heart className={`h-4 w-4 ${showFavoritesOnly ? 'fill-current' : ''}`} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title="Refresh playlists"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
64
src/components/playlists/PlaylistsLoadingStates.tsx
Normal file
64
src/components/playlists/PlaylistsLoadingStates.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
interface PlaylistsLoadingProps {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function PlaylistsLoading({ count = 5 }: PlaylistsLoadingProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PlaylistsErrorProps {
|
||||
error: string
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function PlaylistsError({ error, onRetry }: PlaylistsErrorProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Failed to load playlists</h3>
|
||||
<p className="text-muted-foreground mb-4">{error}</p>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PlaylistsEmptyProps {
|
||||
searchQuery: string
|
||||
showFavoritesOnly?: boolean
|
||||
}
|
||||
|
||||
export function PlaylistsEmpty({ searchQuery, showFavoritesOnly = false }: PlaylistsEmptyProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<span className="text-2xl">{showFavoritesOnly ? '💝' : '🎵'}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{showFavoritesOnly ? 'No favorite playlists found' : 'No playlists found'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{showFavoritesOnly
|
||||
? 'You haven\'t favorited any playlists yet. Click the heart icon on playlists to add them to your favorites.'
|
||||
: searchQuery
|
||||
? 'No playlists match your search criteria.'
|
||||
: 'No playlists are available.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
src/components/playlists/playlist-edit/AvailableSound.tsx
Normal file
56
src/components/playlists/playlist-edit/AvailableSound.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Sound } from '@/lib/api/services/sounds'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Music, Plus } from 'lucide-react'
|
||||
|
||||
interface AvailableSoundProps {
|
||||
sound: Sound
|
||||
onAddToPlaylist: (soundId: number) => void
|
||||
}
|
||||
|
||||
export function AvailableSound({ sound, onAddToPlaylist }: AvailableSoundProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: `available-sound-${sound.id}` })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="flex items-center gap-3 p-3 border rounded-lg hover:bg-muted/50 cursor-grab active:cursor-grabbing group"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">{sound.name}</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onAddToPlaylist(sound.id)
|
||||
}}
|
||||
className="h-4 w-4 p-0 text-primary hover:text-primary flex-shrink-0"
|
||||
title="Add to playlist"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { PlaylistSound } from '@/lib/api/services/playlists'
|
||||
import type { Sound } from '@/lib/api/services/sounds'
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { Music } from 'lucide-react'
|
||||
|
||||
// Simple drop area for the end of the playlist
|
||||
export function EndDropArea() {
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: 'playlist-end',
|
||||
data: { type: 'playlist-end' },
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className="h-8 w-full" // Invisible drop area
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline preview component that shows where the sound will be dropped
|
||||
interface InlinePreviewProps {
|
||||
sound: Sound | PlaylistSound
|
||||
position: number
|
||||
}
|
||||
|
||||
export function InlinePreview({ sound, position }: InlinePreviewProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 border-2 border-dashed border-primary rounded-lg bg-primary/10 animate-pulse">
|
||||
<span className="text-sm font-mono text-primary min-w-[1.5rem] text-center flex-shrink-0">
|
||||
{position + 1}
|
||||
</span>
|
||||
|
||||
<Music className="h-4 w-4 text-primary flex-shrink-0" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate text-primary">{sound.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Drag overlay component that shows the dragged item
|
||||
interface DragOverlayContentProps {
|
||||
sound: Sound | PlaylistSound
|
||||
position?: number
|
||||
}
|
||||
|
||||
export function DragOverlayContent({ sound, position }: DragOverlayContentProps) {
|
||||
// If position is provided, show as current playlist style
|
||||
if (position !== undefined) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 border rounded-lg bg-background shadow-lg">
|
||||
<span className="text-sm font-mono text-muted-foreground min-w-[1.5rem] text-center flex-shrink-0">
|
||||
{position + 1}
|
||||
</span>
|
||||
|
||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">{sound.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default available sound style
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 border rounded-lg bg-background shadow-lg">
|
||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">{sound.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
153
src/components/playlists/playlist-edit/PlaylistDetailsCard.tsx
Normal file
153
src/components/playlists/playlist-edit/PlaylistDetailsCard.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { Playlist } from '@/lib/api/services/playlists'
|
||||
import { Edit, Music, Save, X } from 'lucide-react'
|
||||
|
||||
interface PlaylistDetailsCardProps {
|
||||
playlist: Playlist
|
||||
isEditMode: boolean
|
||||
formData: {
|
||||
name: string
|
||||
description: string
|
||||
genre: string
|
||||
}
|
||||
hasChanges: boolean
|
||||
saving: boolean
|
||||
onInputChange: (field: string, value: string) => void
|
||||
onSave: () => void
|
||||
onCancelEdit: () => void
|
||||
onStartEdit: () => void
|
||||
}
|
||||
|
||||
export function PlaylistDetailsCard({
|
||||
playlist,
|
||||
isEditMode,
|
||||
formData,
|
||||
hasChanges,
|
||||
saving,
|
||||
onInputChange,
|
||||
onSave,
|
||||
onCancelEdit,
|
||||
onStartEdit,
|
||||
}: PlaylistDetailsCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Music className="h-5 w-5" />
|
||||
Playlist Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={e => onInputChange('name', e.target.value)}
|
||||
placeholder="Playlist name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={e => onInputChange('description', e.target.value)}
|
||||
placeholder="Playlist description"
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="genre">Genre</Label>
|
||||
<Input
|
||||
id="genre"
|
||||
value={formData.genre}
|
||||
onChange={e => onInputChange('genre', e.target.value)}
|
||||
placeholder="Electronic, Rock, Comedy, etc."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Name
|
||||
</Label>
|
||||
<p className="text-lg font-semibold">{playlist.name}</p>
|
||||
</div>
|
||||
|
||||
{playlist.description && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Description
|
||||
</Label>
|
||||
<p className="text-sm">{playlist.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{playlist.genre && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Genre
|
||||
</Label>
|
||||
<Badge variant="secondary" className="mt-1">
|
||||
{playlist.genre}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!playlist.description && !playlist.genre && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No additional details provided
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Edit/Save/Cancel buttons */}
|
||||
<div className="pt-4 border-t">
|
||||
{playlist.is_main ? (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Main playlist details cannot be edited
|
||||
</p>
|
||||
</div>
|
||||
) : isEditMode ? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onCancelEdit}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={!hasChanges || saving}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onStartEdit}
|
||||
className="w-full"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Playlist } from '@/lib/api/services/playlists'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PlaylistEditHeaderProps {
|
||||
playlist: Playlist
|
||||
isEditMode: boolean
|
||||
onSetCurrent: () => void
|
||||
onFavoriteToggle?: (playlistId: number, shouldFavorite: boolean) => void
|
||||
}
|
||||
|
||||
export function PlaylistEditHeader({
|
||||
playlist,
|
||||
isEditMode,
|
||||
onSetCurrent,
|
||||
onFavoriteToggle,
|
||||
}: PlaylistEditHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{playlist.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage your playlist
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onFavoriteToggle && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onFavoriteToggle(playlist.id, !playlist.is_favorited)}
|
||||
className="h-8 w-8 p-0"
|
||||
title={playlist.is_favorited ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
<Heart
|
||||
className={cn(
|
||||
'h-4 w-4 transition-all duration-200',
|
||||
playlist.is_favorited
|
||||
? 'fill-current text-red-500 hover:text-red-600'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{!playlist.is_current && !isEditMode && (
|
||||
<Button variant="outline" onClick={onSetCurrent}>
|
||||
Set as Current
|
||||
</Button>
|
||||
)}
|
||||
{playlist.is_current && (
|
||||
<Badge variant="default">Current Playlist</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
interface PlaylistEditLoadingProps {
|
||||
playlistName?: string
|
||||
}
|
||||
|
||||
export function PlaylistEditLoading({ playlistName }: PlaylistEditLoadingProps) {
|
||||
return (
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Playlists', href: '/playlists' },
|
||||
{ label: playlistName || 'Edit' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4 space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Skeleton className="h-96" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
interface PlaylistEditErrorProps {
|
||||
error: string
|
||||
onBackToPlaylists: () => void
|
||||
}
|
||||
|
||||
export function PlaylistEditError({ error, onBackToPlaylists }: PlaylistEditErrorProps) {
|
||||
return (
|
||||
<AppLayout
|
||||
breadcrumb={{
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Playlists', href: '/playlists' },
|
||||
{ label: 'Edit' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
Failed to load playlist
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">{error}</p>
|
||||
<button
|
||||
onClick={onBackToPlaylists}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Back to playlists
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
76
src/components/playlists/playlist-edit/PlaylistStatsCard.tsx
Normal file
76
src/components/playlists/playlist-edit/PlaylistStatsCard.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { NumberFlowDuration } from '@/components/ui/number-flow-duration'
|
||||
import type { Playlist, PlaylistSound } from '@/lib/api/services/playlists'
|
||||
import { formatDate } from '@/utils/format-date'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { Clock } from 'lucide-react'
|
||||
|
||||
interface PlaylistStatsCardProps {
|
||||
playlist: Playlist
|
||||
sounds: PlaylistSound[]
|
||||
}
|
||||
|
||||
export function PlaylistStatsCard({ playlist, sounds }: PlaylistStatsCardProps) {
|
||||
const totalDuration = sounds.reduce(
|
||||
(total, sound) => total + (sound.duration || 0),
|
||||
0
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
Playlist Statistics
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center p-3 bg-muted rounded-lg">
|
||||
<div className="text-2xl font-bold"><NumberFlow value={sounds.length} /></div>
|
||||
<div className="text-sm text-muted-foreground">Tracks</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-muted rounded-lg">
|
||||
<div className="text-2xl font-bold">
|
||||
<NumberFlowDuration duration={totalDuration} variant='wordy' />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Duration</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Created:</span>
|
||||
<span>
|
||||
{formatDate(playlist.created_at, true, true)}
|
||||
</span>
|
||||
</div>
|
||||
{playlist.updated_at && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Updated:</span>
|
||||
<span>
|
||||
{formatDate(playlist.updated_at, true, true)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Status:</span>
|
||||
<div className="flex gap-1">
|
||||
{playlist.is_main && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Main
|
||||
</Badge>
|
||||
)}
|
||||
{playlist.is_current && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Current
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
69
src/components/playlists/playlist-edit/SimpleSortableRow.tsx
Normal file
69
src/components/playlists/playlist-edit/SimpleSortableRow.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { PlaylistSound } from '@/lib/api/services/playlists'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Music, X } from 'lucide-react'
|
||||
|
||||
interface SimpleSortableRowProps {
|
||||
sound: PlaylistSound
|
||||
index: number
|
||||
onRemoveSound?: (soundId: number) => void
|
||||
isMainPlaylist?: boolean
|
||||
}
|
||||
|
||||
export function SimpleSortableRow({
|
||||
sound,
|
||||
index,
|
||||
onRemoveSound,
|
||||
isMainPlaylist = false,
|
||||
}: SimpleSortableRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: `playlist-sound-${sound.id}` })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="flex items-center gap-3 p-3 border rounded-lg hover:bg-muted/50 cursor-grab active:cursor-grabbing group"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<span className="text-sm font-mono text-muted-foreground min-w-[1.5rem] text-center flex-shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">{sound.name}</div>
|
||||
</div>
|
||||
|
||||
{onRemoveSound && !isMainPlaylist && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onRemoveSound(sound.id)
|
||||
}}
|
||||
className="h-4 w-4 p-0 text-destructive hover:text-destructive flex-shrink-0"
|
||||
title="Remove from playlist"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
src/components/playlists/playlist-edit/SortableTableRow.tsx
Normal file
117
src/components/playlists/playlist-edit/SortableTableRow.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { TableCell, TableRow } from '@/components/ui/table'
|
||||
import type { PlaylistSound } from '@/lib/api/services/playlists'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { ChevronDown, ChevronUp, Music, Trash2 } from 'lucide-react'
|
||||
|
||||
interface SortableTableRowProps {
|
||||
sound: PlaylistSound
|
||||
index: number
|
||||
onMoveSoundUp: (index: number) => void
|
||||
onMoveSoundDown: (index: number) => void
|
||||
onRemoveSound?: (soundId: number) => void
|
||||
totalSounds: number
|
||||
isMainPlaylist?: boolean
|
||||
}
|
||||
|
||||
export function SortableTableRow({
|
||||
sound,
|
||||
index,
|
||||
onMoveSoundUp,
|
||||
onMoveSoundDown,
|
||||
onRemoveSound,
|
||||
totalSounds,
|
||||
isMainPlaylist = false,
|
||||
}: SortableTableRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: `table-sound-${sound.id}` })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={sound.id}
|
||||
className="hover:bg-muted/50"
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
>
|
||||
<TableCell className="text-center text-muted-foreground font-mono text-sm">
|
||||
<div
|
||||
className="flex items-center justify-center gap-2 cursor-grab active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="w-1 h-1 bg-muted-foreground/60 rounded-full"></div>
|
||||
<div className="w-1 h-1 bg-muted-foreground/60 rounded-full mt-0.5"></div>
|
||||
<div className="w-1 h-1 bg-muted-foreground/60 rounded-full mt-0.5"></div>
|
||||
</div>
|
||||
<span>{index + 1}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Music className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{sound.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatDuration(sound.duration || 0)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{sound.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{sound.play_count}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onMoveSoundUp(index)}
|
||||
disabled={index === 0}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onMoveSoundDown(index)}
|
||||
disabled={index === totalSounds - 1}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
{onRemoveSound && !isMainPlaylist && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onRemoveSound(sound.id)}
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
title="Remove from playlist"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
492
src/components/schedulers/CreateTaskDialog.tsx
Normal file
492
src/components/schedulers/CreateTaskDialog.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
type CreateScheduledTaskRequest,
|
||||
type RecurrenceType,
|
||||
type TaskType,
|
||||
getRecurrenceTypeLabel,
|
||||
getTaskTypeLabel,
|
||||
} from '@/lib/api/services/schedulers'
|
||||
import { soundsService } from '@/lib/api/services/sounds'
|
||||
import { playlistsService } from '@/lib/api/services/playlists'
|
||||
import { getSupportedTimezones } from '@/utils/locale'
|
||||
import { useLocale } from '@/hooks/use-locale'
|
||||
import { CalendarPlus, Loader2, Music, PlayCircle } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface CreateTaskDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
loading: boolean
|
||||
onSubmit: (data: CreateScheduledTaskRequest) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
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({
|
||||
open,
|
||||
onOpenChange,
|
||||
loading,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: CreateTaskDialogProps) {
|
||||
const { timezone } = useLocale()
|
||||
|
||||
const getDefaultScheduledTime = () => {
|
||||
const now = new Date()
|
||||
now.setMinutes(now.getMinutes() + 10) // Default to 10 minutes from now
|
||||
|
||||
// Format the time in the user's timezone
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
const parts = formatter.formatToParts(now)
|
||||
const partsObj = parts.reduce((acc, part) => {
|
||||
acc[part.type] = part.value
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
// Return in datetime-local format (YYYY-MM-DDTHH:MM)
|
||||
return `${partsObj.year}-${partsObj.month}-${partsObj.day}T${partsObj.hour}:${partsObj.minute}`
|
||||
}
|
||||
|
||||
const [formData, setFormData] = useState<CreateScheduledTaskRequest>({
|
||||
name: '',
|
||||
task_type: 'play_sound',
|
||||
scheduled_at: getDefaultScheduledTime(),
|
||||
timezone: timezone,
|
||||
parameters: {},
|
||||
recurrence_type: 'none',
|
||||
cron_expression: null,
|
||||
recurrence_count: null,
|
||||
expires_at: null,
|
||||
})
|
||||
|
||||
const [parametersJson, setParametersJson] = useState('{}')
|
||||
const [parametersError, setParametersError] = useState<string | null>(null)
|
||||
|
||||
// Task-specific parameters
|
||||
const [selectedSoundId, setSelectedSoundId] = useState<string>('')
|
||||
const [selectedPlaylistId, setSelectedPlaylistId] = useState<string>('')
|
||||
const [playMode, setPlayMode] = useState<string>('continuous')
|
||||
const [shuffle, setShuffle] = useState<boolean>(false)
|
||||
|
||||
// Data loading
|
||||
const [sounds, setSounds] = useState<Array<{id: number, name?: string, filename: string}>>([])
|
||||
const [playlists, setPlaylists] = useState<Array<{id: number, name: string}>>([])
|
||||
const [loadingSounds, setLoadingSounds] = useState(false)
|
||||
const [loadingPlaylists, setLoadingPlaylists] = useState(false)
|
||||
|
||||
|
||||
// Load sounds and playlists when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadSounds()
|
||||
loadPlaylists()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const loadSounds = async () => {
|
||||
setLoadingSounds(true)
|
||||
try {
|
||||
const soundsData = await soundsService.getSounds({
|
||||
types: ['SDB', 'TTS']
|
||||
})
|
||||
setSounds(soundsData || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load sounds:', error)
|
||||
} finally {
|
||||
setLoadingSounds(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadPlaylists = async () => {
|
||||
setLoadingPlaylists(true)
|
||||
try {
|
||||
const playlistsData = await playlistsService.getPlaylists({})
|
||||
setPlaylists(playlistsData.playlists || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load playlists:', error)
|
||||
} finally {
|
||||
setLoadingPlaylists(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare options for comboboxes
|
||||
const soundOptions: ComboboxOption[] = sounds.map((sound) => ({
|
||||
value: sound.id.toString(),
|
||||
label: sound.name || sound.filename,
|
||||
icon: <Music className="h-4 w-4" />,
|
||||
searchValue: `${sound.id}-${sound.name || sound.filename}`
|
||||
}))
|
||||
|
||||
const playlistOptions: ComboboxOption[] = playlists.map((playlist) => ({
|
||||
value: playlist.id.toString(),
|
||||
label: playlist.name,
|
||||
icon: <PlayCircle className="h-4 w-4" />,
|
||||
searchValue: `${playlist.id}-${playlist.name}`
|
||||
}))
|
||||
|
||||
const timezoneOptions: ComboboxOption[] = [
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
...getSupportedTimezones().map((tz) => ({
|
||||
value: tz,
|
||||
label: tz.replace('_', ' '),
|
||||
searchValue: tz.replace('_', ' ')
|
||||
}))
|
||||
]
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Build parameters based on task type
|
||||
let parameters: Record<string, unknown> = {}
|
||||
|
||||
if (formData.task_type === 'play_sound') {
|
||||
if (!selectedSoundId) {
|
||||
setParametersError('Please select a sound')
|
||||
return
|
||||
}
|
||||
parameters = { sound_id: parseInt(selectedSoundId) }
|
||||
} else if (formData.task_type === 'play_playlist') {
|
||||
if (!selectedPlaylistId) {
|
||||
setParametersError('Please select a playlist')
|
||||
return
|
||||
}
|
||||
parameters = {
|
||||
playlist_id: parseInt(selectedPlaylistId),
|
||||
play_mode: playMode,
|
||||
shuffle: shuffle,
|
||||
}
|
||||
} else if (formData.task_type === 'credit_recharge') {
|
||||
// For credit recharge, use the JSON textarea for custom parameters
|
||||
try {
|
||||
parameters = JSON.parse(parametersJson)
|
||||
} catch {
|
||||
setParametersError('Invalid JSON format')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Send the datetime as UTC to prevent backend timezone conversion
|
||||
let scheduledAt = formData.scheduled_at
|
||||
if (scheduledAt.length === 16) {
|
||||
scheduledAt += ':00' // Add seconds if missing
|
||||
}
|
||||
|
||||
// Add Z to indicate this is already UTC time, preventing backend conversion
|
||||
const scheduledAtUTC = scheduledAt + 'Z'
|
||||
|
||||
onSubmit({
|
||||
...formData,
|
||||
parameters,
|
||||
scheduled_at: scheduledAtUTC,
|
||||
})
|
||||
}
|
||||
|
||||
const handleParametersChange = (value: string) => {
|
||||
setParametersJson(value)
|
||||
setParametersError(null)
|
||||
|
||||
// Try to parse JSON to validate
|
||||
try {
|
||||
JSON.parse(value)
|
||||
} catch {
|
||||
if (value.trim()) {
|
||||
setParametersError('Invalid JSON format')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
task_type: 'play_sound',
|
||||
scheduled_at: getDefaultScheduledTime(),
|
||||
timezone: timezone,
|
||||
parameters: {},
|
||||
recurrence_type: 'none',
|
||||
cron_expression: null,
|
||||
recurrence_count: null,
|
||||
expires_at: null,
|
||||
})
|
||||
setParametersJson('{}')
|
||||
setParametersError(null)
|
||||
setSelectedSoundId('')
|
||||
setSelectedPlaylistId('')
|
||||
setPlayMode('continuous')
|
||||
setShuffle(false)
|
||||
onCancel()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CalendarPlus className="h-5 w-5" />
|
||||
Create Scheduled Task
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Task Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Enter task name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task_type">Task Type</Label>
|
||||
<Select
|
||||
value={formData.task_type}
|
||||
onValueChange={(value: TaskType) => setFormData(prev => ({ ...prev, task_type: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TASK_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getTaskTypeLabel(type)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scheduled_at">Scheduled At</Label>
|
||||
<Input
|
||||
id="scheduled_at"
|
||||
type="datetime-local"
|
||||
value={formData.scheduled_at}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, scheduled_at: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timezone">Timezone</Label>
|
||||
<Combobox
|
||||
value={formData.timezone}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, timezone: value }))}
|
||||
options={timezoneOptions}
|
||||
placeholder="Select timezone..."
|
||||
searchPlaceholder="Search timezone..."
|
||||
emptyMessage="No timezone found."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="recurrence_type">Recurrence</Label>
|
||||
<Select
|
||||
value={formData.recurrence_type}
|
||||
onValueChange={(value: RecurrenceType) => setFormData(prev => ({ ...prev, recurrence_type: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RECURRENCE_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getRecurrenceTypeLabel(type)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formData.recurrence_type === 'cron' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cron_expression">Cron Expression</Label>
|
||||
<Input
|
||||
id="cron_expression"
|
||||
value={formData.cron_expression || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, cron_expression: e.target.value }))}
|
||||
placeholder="0 0 * * *"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.recurrence_type !== 'none' && formData.recurrence_type !== 'cron' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="recurrence_count">Max Executions</Label>
|
||||
<Input
|
||||
id="recurrence_count"
|
||||
type="number"
|
||||
value={formData.recurrence_count || ''}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
recurrence_count: e.target.value ? parseInt(e.target.value) : null
|
||||
}))}
|
||||
placeholder="Leave empty for infinite"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expires_at">Expires At (Optional)</Label>
|
||||
<Input
|
||||
id="expires_at"
|
||||
type="datetime-local"
|
||||
value={formData.expires_at || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, expires_at: e.target.value || null }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Task-specific parameters based on task type */}
|
||||
{formData.task_type === 'play_sound' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sound">Sound to Play</Label>
|
||||
<Combobox
|
||||
value={selectedSoundId}
|
||||
onValueChange={setSelectedSoundId}
|
||||
options={soundOptions}
|
||||
placeholder="Select a sound"
|
||||
searchPlaceholder="Search sounds..."
|
||||
emptyMessage="No sound found."
|
||||
loading={loadingSounds}
|
||||
loadingMessage="Loading sounds..."
|
||||
/>
|
||||
{parametersError && (
|
||||
<p className="text-sm text-destructive">{parametersError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.task_type === 'play_playlist' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="playlist">Playlist to Play</Label>
|
||||
<Combobox
|
||||
value={selectedPlaylistId}
|
||||
onValueChange={setSelectedPlaylistId}
|
||||
options={playlistOptions}
|
||||
placeholder="Select a playlist"
|
||||
searchPlaceholder="Search playlists..."
|
||||
emptyMessage="No playlist found."
|
||||
loading={loadingPlaylists}
|
||||
loadingMessage="Loading playlists..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="playMode">Play Mode</Label>
|
||||
<Select
|
||||
value={playMode}
|
||||
onValueChange={setPlayMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="continuous">Continuous</SelectItem>
|
||||
<SelectItem value="loop">Loop</SelectItem>
|
||||
<SelectItem value="loop_one">Loop One</SelectItem>
|
||||
<SelectItem value="random">Random</SelectItem>
|
||||
<SelectItem value="single">Single</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="shuffle">Shuffle</Label>
|
||||
<Select
|
||||
value={shuffle.toString()}
|
||||
onValueChange={(value) => setShuffle(value === 'true')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="false">No</SelectItem>
|
||||
<SelectItem value="true">Yes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{parametersError && (
|
||||
<p className="text-sm text-destructive">{parametersError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.task_type === 'credit_recharge' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parameters">Credit Recharge Parameters</Label>
|
||||
<Textarea
|
||||
id="parameters"
|
||||
value={parametersJson}
|
||||
onChange={(e) => handleParametersChange(e.target.value)}
|
||||
placeholder='{"user_id": 123} or {} for all users'
|
||||
className="font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
{parametersError && (
|
||||
<p className="text-sm text-destructive">{parametersError}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional: specify {"user_id"} to recharge specific user, or leave empty {} to recharge all users.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !!parametersError}>
|
||||
{loading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Create Task
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
144
src/components/schedulers/SchedulersHeader.tsx
Normal file
144
src/components/schedulers/SchedulersHeader.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
type TaskStatus,
|
||||
type TaskType,
|
||||
getTaskStatusLabel,
|
||||
getTaskTypeLabel,
|
||||
} from '@/lib/api/services/schedulers'
|
||||
import {
|
||||
Filter,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SchedulersHeaderProps {
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
statusFilter: TaskStatus | 'all'
|
||||
onStatusFilterChange: (status: TaskStatus | 'all') => void
|
||||
taskTypeFilter: TaskType | 'all'
|
||||
onTaskTypeFilterChange: (taskType: TaskType | 'all') => void
|
||||
onRefresh: () => void
|
||||
onCreateClick: () => void
|
||||
loading: boolean
|
||||
error: string | null
|
||||
taskCount: number
|
||||
}
|
||||
|
||||
const TASK_STATUSES: TaskStatus[] = ['pending', 'running', 'completed', 'failed', 'cancelled']
|
||||
const TASK_TYPES: TaskType[] = [/*'credit_recharge',*/ 'play_sound', 'play_playlist']
|
||||
|
||||
export function SchedulersHeader({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
taskTypeFilter,
|
||||
onTaskTypeFilterChange,
|
||||
onRefresh,
|
||||
onCreateClick,
|
||||
loading,
|
||||
error,
|
||||
taskCount,
|
||||
}: SchedulersHeaderProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Scheduled Tasks</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your scheduled tasks and automation
|
||||
{taskCount > 0 && (
|
||||
<span className="ml-2 text-sm">
|
||||
({taskCount} task{taskCount !== 1 ? 's' : ''})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={onCreateClick}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Extraction
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Search tasks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={onStatusFilterChange}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="All Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
{TASK_STATUSES.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{getTaskStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={taskTypeFilter}
|
||||
onValueChange={onTaskTypeFilterChange}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="All Types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
{TASK_TYPES.map((taskType) => (
|
||||
<SelectItem key={taskType} value={taskType}>
|
||||
{getTaskTypeLabel(taskType)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-md p-4">
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
src/components/schedulers/SchedulersLoadingStates.tsx
Normal file
124
src/components/schedulers/SchedulersLoadingStates.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AlertCircle, CalendarClock, RefreshCw } from 'lucide-react'
|
||||
|
||||
interface SchedulersErrorProps {
|
||||
error: string
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function SchedulersLoading() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-4 pt-4 border-t">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SchedulersError({ error, onRetry }: SchedulersErrorProps) {
|
||||
return (
|
||||
<Card className="border-destructive/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center justify-center text-center py-8">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Error Loading Tasks</h3>
|
||||
<p className="text-muted-foreground mb-4 max-w-sm">{error}</p>
|
||||
<Button onClick={onRetry} variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface SchedulersEmptyProps {
|
||||
searchQuery: string
|
||||
statusFilter: string
|
||||
taskTypeFilter: string
|
||||
}
|
||||
|
||||
export function SchedulersEmpty({
|
||||
searchQuery,
|
||||
statusFilter,
|
||||
taskTypeFilter,
|
||||
}: SchedulersEmptyProps) {
|
||||
const hasFilters = searchQuery.trim() || statusFilter !== 'all' || taskTypeFilter !== 'all'
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center justify-center text-center py-8">
|
||||
<CalendarClock className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{hasFilters ? 'No matching tasks found' : 'No scheduled tasks'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4 max-w-sm">
|
||||
{hasFilters
|
||||
? 'Try adjusting your search criteria or filters to find tasks.'
|
||||
: 'Get started by creating your first scheduled task for automation.'}
|
||||
</p>
|
||||
{hasFilters && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchQuery && (
|
||||
<span>
|
||||
Search: <code className="bg-muted px-1 rounded">{searchQuery}</code>
|
||||
</span>
|
||||
)}
|
||||
{statusFilter !== 'all' && (
|
||||
<span className="ml-2">
|
||||
Status: <code className="bg-muted px-1 rounded">{statusFilter}</code>
|
||||
</span>
|
||||
)}
|
||||
{taskTypeFilter !== 'all' && (
|
||||
<span className="ml-2">
|
||||
Type: <code className="bg-muted px-1 rounded">{taskTypeFilter}</code>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
180
src/components/schedulers/SchedulersTable.tsx
Normal file
180
src/components/schedulers/SchedulersTable.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { formatDate } from '@/utils/format-date'
|
||||
import {
|
||||
type ScheduledTask,
|
||||
getRecurrenceTypeLabel,
|
||||
getTaskStatusLabel,
|
||||
getTaskStatusVariant,
|
||||
getTaskTypeLabel,
|
||||
schedulersService,
|
||||
} from '@/lib/api/services/schedulers'
|
||||
import {
|
||||
CalendarClock,
|
||||
MoreHorizontal,
|
||||
Square,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface SchedulersTableProps {
|
||||
tasks: ScheduledTask[]
|
||||
onTaskDeleted?: (taskId: number) => void
|
||||
}
|
||||
|
||||
export function SchedulersTable({ tasks, onTaskDeleted }: SchedulersTableProps) {
|
||||
const [loadingActions, setLoadingActions] = useState<Record<number, boolean>>({})
|
||||
|
||||
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 }))
|
||||
|
||||
await schedulersService.cancelTask(task.id)
|
||||
onTaskDeleted?.(task.id)
|
||||
toast.success('Task deleted successfully')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to delete task'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoadingActions(prev => ({ ...prev, [task.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<CalendarClock className="h-12 w-12 mx-auto mb-4" />
|
||||
<p>No scheduled tasks found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{tasks.map((task) => (
|
||||
<Card key={task.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{task.name}</h3>
|
||||
<Badge variant={getTaskStatusVariant(task.status)}>
|
||||
{getTaskStatusLabel(task.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getTaskTypeLabel(task.task_type)}
|
||||
{task.recurrence_type !== 'none' && (
|
||||
<span className="ml-2">
|
||||
• {getRecurrenceTypeLabel(task.recurrence_type)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={loadingActions[task.id]}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleCancelTask(task)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
Delete Task
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Scheduled</p>
|
||||
<p className="font-medium">
|
||||
{formatDate(task.scheduled_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Next Run</p>
|
||||
<p className="font-medium">
|
||||
{task.next_execution_at
|
||||
? formatDate(task.next_execution_at)
|
||||
: task.status === 'completed'
|
||||
? 'Completed'
|
||||
: task.status === 'cancelled'
|
||||
? 'Cancelled'
|
||||
: 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Executions</p>
|
||||
<p className="font-medium">{task.executions_count}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Last Run</p>
|
||||
<p className="font-medium">
|
||||
{task.last_executed_at
|
||||
? formatDate(task.last_executed_at)
|
||||
: 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.error_message && (
|
||||
<div className="mt-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-sm text-destructive">{task.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(task.parameters).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
Parameters ({Object.keys(task.parameters).length})
|
||||
</summary>
|
||||
<div className="mt-2 p-3 bg-muted rounded-md">
|
||||
<pre className="text-xs overflow-x-auto">
|
||||
{JSON.stringify(task.parameters, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
406
src/components/sequencer/SequencerCanvas.tsx
Normal file
406
src/components/sequencer/SequencerCanvas.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import { useDroppable, useDraggable } from '@dnd-kit/core'
|
||||
import type { Track, PlacedSound } from '@/pages/SequencerPage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Trash2, Volume2 } from 'lucide-react'
|
||||
import { forwardRef, useRef, useEffect } from 'react'
|
||||
|
||||
interface SequencerCanvasProps {
|
||||
tracks: Track[]
|
||||
duration: number
|
||||
zoom: number
|
||||
currentTime: number
|
||||
isPlaying: boolean
|
||||
onScroll?: () => void
|
||||
draggedItem?: any // Current dragged item from parent
|
||||
dragOverInfo?: {trackId: string, x: number} | null // Drag over position info
|
||||
onRemoveSound: (soundId: string, trackId: string) => void
|
||||
timeIntervals: {minorIntervals: number[], majorIntervals: number[], minorInterval: number, majorInterval: number}
|
||||
onZoomChange?: (newZoom: number, mouseX?: number) => void
|
||||
minZoom?: number
|
||||
maxZoom?: number
|
||||
}
|
||||
|
||||
interface TrackRowProps {
|
||||
track: Track
|
||||
duration: number
|
||||
zoom: number
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
draggedItem?: any // Current dragged item
|
||||
dragOverInfo?: {trackId: string, x: number} | null // Drag over position info
|
||||
onRemoveSound: (soundId: string, trackId: string) => void
|
||||
timeIntervals: {minorIntervals: number[], majorIntervals: number[], minorInterval: number, majorInterval: number}
|
||||
}
|
||||
|
||||
interface PlacedSoundItemProps {
|
||||
sound: PlacedSound
|
||||
zoom: number
|
||||
trackId: string
|
||||
onRemove: (soundId: string) => void
|
||||
}
|
||||
|
||||
function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
isDragging,
|
||||
} = useDraggable({
|
||||
id: sound.id,
|
||||
data: {
|
||||
type: 'placed-sound',
|
||||
...sound,
|
||||
trackId,
|
||||
},
|
||||
})
|
||||
|
||||
const style = transform ? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
} : undefined
|
||||
|
||||
const width = (sound.duration / 1000) * zoom // Convert ms to seconds for zoom calculation
|
||||
// Ensure placed sounds are positioned at 100ms snapped locations
|
||||
const startTimeSeconds = sound.startTime / 1000
|
||||
const snapIntervalMs = 100 // 100ms snap interval
|
||||
const snappedStartTime = Math.round((startTimeSeconds * 1000) / snapIntervalMs) * snapIntervalMs / 1000
|
||||
const left = Math.max(0, snappedStartTime) * zoom
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
...style,
|
||||
position: 'absolute',
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
top: '8px',
|
||||
bottom: '8px',
|
||||
}}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`
|
||||
bg-primary/20 border-2 border-primary/40 rounded
|
||||
flex items-center justify-between px-2 text-xs
|
||||
cursor-grab active:cursor-grabbing
|
||||
hover:bg-primary/30 hover:border-primary/60
|
||||
group transition-colors
|
||||
${isDragging ? 'opacity-50 z-10' : 'z-0'}
|
||||
`}
|
||||
title={`${sound.name} (${formatTime(sound.duration / 1000)})`}
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Volume2 className="h-3 w-3 flex-shrink-0 text-primary" />
|
||||
<span className="truncate font-medium text-primary">
|
||||
{sound.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove(sound.id)
|
||||
}}
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
title="Remove sound"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, dragOverInfo, onRemoveSound, timeIntervals }: TrackRowProps) {
|
||||
const playheadPosition = (currentTime / 1000) * zoom // Convert ms to seconds for zoom calculation
|
||||
|
||||
const { isOver, setNodeRef: setDropRef } = useDroppable({
|
||||
id: `track-${track.id}`,
|
||||
data: {
|
||||
type: 'track',
|
||||
trackId: track.id,
|
||||
},
|
||||
})
|
||||
|
||||
const handleRemoveSound = (soundId: string) => {
|
||||
onRemoveSound(soundId, track.id)
|
||||
}
|
||||
|
||||
const { minorIntervals, majorIntervals } = timeIntervals
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ height: '80px' }}>
|
||||
<div
|
||||
ref={setDropRef}
|
||||
id={`track-${track.id}`}
|
||||
className={`
|
||||
h-full border-b border-border/50
|
||||
relative overflow-hidden
|
||||
${isOver ? 'bg-accent/30' : 'bg-muted/10'}
|
||||
transition-colors
|
||||
`}
|
||||
>
|
||||
{/* Grid lines for time markers */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{/* Minor grid lines */}
|
||||
{minorIntervals.map((time) => (
|
||||
<div
|
||||
key={`minor-${time}`}
|
||||
className="absolute top-0 bottom-0 w-px bg-border/30"
|
||||
style={{ left: `${time * zoom}px` }}
|
||||
/>
|
||||
))}
|
||||
{/* Major grid lines */}
|
||||
{majorIntervals.map((time) => (
|
||||
<div
|
||||
key={`major-${time}`}
|
||||
className="absolute top-0 bottom-0 w-px bg-border/60"
|
||||
style={{ left: `${time * zoom}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Precise drag preview (dragOverInfo.x is already snapped) */}
|
||||
{draggedItem && dragOverInfo && dragOverInfo.trackId === track.id && (() => {
|
||||
const soundDurationMs = draggedItem.type === 'sound'
|
||||
? draggedItem.sound.duration // Already in ms
|
||||
: draggedItem.duration // Already in ms
|
||||
const soundDurationSeconds = soundDurationMs / 1000
|
||||
|
||||
// dragOverInfo.x is already snapped in the parent component
|
||||
const startTimeSeconds = dragOverInfo.x / zoom
|
||||
const endTimeSeconds = startTimeSeconds + soundDurationSeconds
|
||||
const durationSeconds = duration / 1000
|
||||
const isValidPosition = startTimeSeconds >= 0 && endTimeSeconds <= durationSeconds
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-2 bottom-2 border-2 border-dashed rounded pointer-events-none z-10 flex items-center px-2 ${
|
||||
isValidPosition
|
||||
? 'border-primary/60 bg-primary/10'
|
||||
: 'border-red-500/60 bg-red-500/10'
|
||||
}`}
|
||||
style={{
|
||||
left: `${Math.max(0, dragOverInfo.x)}px`,
|
||||
width: `${Math.max(60, soundDurationSeconds * zoom)}px`,
|
||||
}}
|
||||
>
|
||||
<div className={`text-xs truncate font-medium ${
|
||||
isValidPosition ? 'text-primary/80' : 'text-red-500/80'
|
||||
}`}>
|
||||
{draggedItem.type === 'sound'
|
||||
? (draggedItem.sound.name || draggedItem.sound.filename)
|
||||
: draggedItem.name
|
||||
}
|
||||
{!isValidPosition && ' (Invalid)'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Playhead */}
|
||||
{isPlaying && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-none z-30"
|
||||
style={{ left: `${playheadPosition}px` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Placed sounds */}
|
||||
{track.sounds.map((sound) => (
|
||||
<PlacedSoundItem
|
||||
key={sound.id}
|
||||
sound={sound}
|
||||
zoom={zoom}
|
||||
trackId={track.id}
|
||||
onRemove={handleRemoveSound}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SequencerCanvas = forwardRef<HTMLDivElement, SequencerCanvasProps>(({
|
||||
tracks,
|
||||
duration,
|
||||
zoom,
|
||||
currentTime,
|
||||
isPlaying,
|
||||
onScroll,
|
||||
draggedItem,
|
||||
dragOverInfo,
|
||||
onRemoveSound,
|
||||
timeIntervals,
|
||||
onZoomChange,
|
||||
minZoom = 10,
|
||||
maxZoom = 200,
|
||||
}, ref) => {
|
||||
const totalWidth = (duration / 1000) * zoom // Convert ms to seconds for zoom calculation
|
||||
const timelineRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Add a fallback droppable for the entire canvas area
|
||||
const { setNodeRef: setCanvasDropRef } = useDroppable({
|
||||
id: 'sequencer-canvas',
|
||||
data: {
|
||||
type: 'canvas',
|
||||
},
|
||||
})
|
||||
|
||||
const { minorIntervals, majorIntervals } = timeIntervals
|
||||
|
||||
const handleTracksScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
// Sync timeline horizontal scroll with tracks
|
||||
if (timelineRef.current) {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
// Only update if different to prevent scroll fighting
|
||||
if (Math.abs(timelineRef.current.scrollLeft - scrollLeft) > 1) {
|
||||
timelineRef.current.scrollLeft = scrollLeft
|
||||
}
|
||||
}
|
||||
// Call the original scroll handler for vertical sync
|
||||
onScroll?.()
|
||||
}
|
||||
|
||||
// Handle mouse wheel zoom with Ctrl key using native event listeners
|
||||
useEffect(() => {
|
||||
if (!onZoomChange) return
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (!e.ctrlKey) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Use the same discrete steps as the zoom buttons (+5/-5)
|
||||
const zoomStep = 5
|
||||
const delta = e.deltaY > 0 ? -zoomStep : zoomStep // Inverted for natural feel
|
||||
const newZoom = Math.min(Math.max(zoom + delta, minZoom), maxZoom)
|
||||
|
||||
if (newZoom !== zoom) {
|
||||
// Get mouse position relative to the scrollable content
|
||||
const target = e.target as HTMLElement
|
||||
const scrollContainer = target.closest('[data-scroll-container]') as HTMLElement
|
||||
if (scrollContainer) {
|
||||
const rect = scrollContainer.getBoundingClientRect()
|
||||
const mouseX = e.clientX - rect.left + scrollContainer.scrollLeft
|
||||
onZoomChange(newZoom, mouseX)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add wheel event listeners to both timeline and tracks
|
||||
const timelineElement = timelineRef.current
|
||||
const tracksElement = ref && typeof ref === 'object' && ref.current ? ref.current : null
|
||||
|
||||
if (timelineElement) {
|
||||
timelineElement.addEventListener('wheel', handleWheel, { passive: false })
|
||||
}
|
||||
if (tracksElement) {
|
||||
tracksElement.addEventListener('wheel', handleWheel, { passive: false })
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timelineElement) {
|
||||
timelineElement.removeEventListener('wheel', handleWheel)
|
||||
}
|
||||
if (tracksElement) {
|
||||
tracksElement.removeEventListener('wheel', handleWheel)
|
||||
}
|
||||
}
|
||||
}, [onZoomChange, zoom, minZoom, maxZoom, ref])
|
||||
|
||||
return (
|
||||
<div ref={setCanvasDropRef} className="h-full flex flex-col overflow-hidden">
|
||||
{/* Time ruler */}
|
||||
<div className="h-8 bg-muted/50 border-b border-border/50 flex-shrink-0 overflow-hidden">
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className="h-full overflow-x-auto [&::-webkit-scrollbar]:hidden"
|
||||
style={{
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none'
|
||||
}}
|
||||
data-scroll-container
|
||||
>
|
||||
<div className="relative h-full" style={{ width: `${totalWidth}px` }}>
|
||||
{/* Minor time markers */}
|
||||
{minorIntervals.map((time) => (
|
||||
<div key={`ruler-minor-${time}`} className="absolute top-0 bottom-0" style={{ left: `${time * zoom}px` }}>
|
||||
<div className="absolute top-0 w-px h-2 bg-border/40" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Major time markers with labels */}
|
||||
{majorIntervals.map((time) => {
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (seconds < 60) {
|
||||
// For times under 1 minute, show seconds with decimal places if needed
|
||||
return seconds < 10 && seconds % 1 !== 0
|
||||
? seconds.toFixed(1) + 's'
|
||||
: Math.floor(seconds) + 's'
|
||||
} else {
|
||||
// For times over 1 minute, show MM:SS format
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`ruler-major-${time}`} className="absolute top-0 bottom-0" style={{ left: `${time * zoom}px` }}>
|
||||
<div className="absolute top-0 w-px h-3 bg-border/60" />
|
||||
<div className="absolute top-4 text-xs text-muted-foreground font-mono whitespace-nowrap">
|
||||
{formatTime(time)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Playhead in ruler */}
|
||||
{isPlaying && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-30"
|
||||
style={{ left: `${(currentTime / 1000) * zoom}px` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tracks */}
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex-1 overflow-auto"
|
||||
onScroll={handleTracksScroll}
|
||||
data-scroll-container
|
||||
>
|
||||
<div style={{ width: `${totalWidth}px`, paddingBottom: '52px' }}>
|
||||
{tracks.map((track) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
duration={duration}
|
||||
zoom={zoom}
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
draggedItem={draggedItem}
|
||||
dragOverInfo={dragOverInfo}
|
||||
onRemoveSound={onRemoveSound}
|
||||
timeIntervals={timeIntervals}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
SequencerCanvas.displayName = 'SequencerCanvas'
|
||||
262
src/components/sequencer/SoundLibrary.tsx
Normal file
262
src/components/sequencer/SoundLibrary.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { soundsService, type Sound } from '@/lib/api/services/sounds'
|
||||
import {
|
||||
AlertCircle,
|
||||
Music,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Volume2,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
import { formatSize } from '@/utils/format-size'
|
||||
|
||||
interface DraggableSoundProps {
|
||||
sound: Sound
|
||||
}
|
||||
|
||||
function DraggableSound({ sound }: DraggableSoundProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
} = useDraggable({
|
||||
id: `sound-${sound.id}`,
|
||||
data: {
|
||||
type: 'sound',
|
||||
sound,
|
||||
},
|
||||
})
|
||||
|
||||
// Don't apply transform to prevent layout shift - DragOverlay handles the visual feedback
|
||||
const style = undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`
|
||||
group cursor-grab active:cursor-grabbing
|
||||
p-3 border rounded-lg bg-card hover:bg-accent/50 transition-colors
|
||||
${isDragging ? 'opacity-30' : ''}
|
||||
`}
|
||||
title={`Drag to add "${sound.name || sound.filename}" to a track`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{sound.type === 'SDB' && <Volume2 className="h-4 w-4 text-blue-500" />}
|
||||
{sound.type === 'TTS' && <span className="text-xs font-bold text-green-500">TTS</span>}
|
||||
{sound.type === 'EXT' && <Music className="h-4 w-4 text-purple-500" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm break-words">
|
||||
{sound.name || sound.filename}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
|
||||
<span>{formatDuration(sound.duration)}</span>
|
||||
<span>•</span>
|
||||
<span>{formatSize(sound.size)}</span>
|
||||
{sound.play_count > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{sound.play_count} plays</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SoundLibrary() {
|
||||
const [sounds, setSounds] = useState<Sound[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [soundType, setSoundType] = useState<'all' | 'SDB' | 'TTS' | 'EXT'>('all')
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
|
||||
|
||||
// Debounce search query
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery)
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(handler)
|
||||
}, [searchQuery])
|
||||
|
||||
const fetchSounds = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Build API params
|
||||
const params: { types?: string[]; search?: string } = {}
|
||||
|
||||
// Filter by type
|
||||
if (soundType !== 'all') {
|
||||
params.types = [soundType]
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
params.search = debouncedSearchQuery.trim()
|
||||
}
|
||||
|
||||
const fetchedSounds = await soundsService.getSounds(params)
|
||||
setSounds(fetchedSounds)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch sounds'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [debouncedSearchQuery, soundType])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSounds()
|
||||
}, [fetchSounds])
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-muted-foreground mb-3" />
|
||||
<h3 className="font-semibold mb-2">Failed to load sounds</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={fetchSounds}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (sounds.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<Music className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">No sounds found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchQuery
|
||||
? `No sounds match "${searchQuery}"`
|
||||
: 'No sounds available in your library'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-2">
|
||||
{sounds.map((sound) => (
|
||||
<DraggableSound key={sound.id} sound={sound} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="pb-4 border-b">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold">Sound Library</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchSounds}
|
||||
disabled={loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search sounds..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-9 h-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<Select
|
||||
value={soundType}
|
||||
onValueChange={(value: 'all' | 'SDB' | 'TTS' | 'EXT') => setSoundType(value)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="SDB">Soundboard (SDB)</SelectItem>
|
||||
<SelectItem value="TTS">Text-to-Speech (TTS)</SelectItem>
|
||||
<SelectItem value="EXT">Extracted (EXT)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 pt-4 min-h-0">
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!loading && !error && (
|
||||
<div className="pt-3 border-t">
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{sounds.length} sound{sounds.length !== 1 ? 's' : ''}
|
||||
{searchQuery && ` matching "${searchQuery}"`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
157
src/components/sequencer/TimelineControls.tsx
Normal file
157
src/components/sequencer/TimelineControls.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Minus, Plus, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface TimelineControlsProps {
|
||||
duration: number // in milliseconds
|
||||
zoom: number
|
||||
onDurationChange: (duration: number) => void // expects milliseconds
|
||||
onZoomChange: (zoom: number) => void
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
}
|
||||
|
||||
export function TimelineControls({
|
||||
duration,
|
||||
zoom,
|
||||
onDurationChange,
|
||||
onZoomChange,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
}: TimelineControlsProps) {
|
||||
const durationInSeconds = duration / 1000
|
||||
const [durationInput, setDurationInput] = useState(durationInSeconds.toString())
|
||||
|
||||
const handleDurationInputChange = (value: string) => {
|
||||
setDurationInput(value)
|
||||
const numValue = parseFloat(value)
|
||||
if (!isNaN(numValue) && numValue > 0 && numValue <= 600) { // Max 10 minutes
|
||||
onDurationChange(numValue * 1000) // Convert to milliseconds
|
||||
}
|
||||
}
|
||||
|
||||
const handleDurationInputBlur = () => {
|
||||
const numValue = parseFloat(durationInput)
|
||||
if (isNaN(numValue) || numValue <= 0) {
|
||||
setDurationInput(durationInSeconds.toString())
|
||||
}
|
||||
}
|
||||
|
||||
const increaseDuration = () => {
|
||||
const newDurationSeconds = Math.min(600, durationInSeconds + 10)
|
||||
onDurationChange(newDurationSeconds * 1000) // Convert to milliseconds
|
||||
setDurationInput(newDurationSeconds.toString())
|
||||
}
|
||||
|
||||
const decreaseDuration = () => {
|
||||
const newDurationSeconds = Math.max(5, durationInSeconds - 10)
|
||||
onDurationChange(newDurationSeconds * 1000) // Convert to milliseconds
|
||||
setDurationInput(newDurationSeconds.toString())
|
||||
}
|
||||
|
||||
const increaseZoom = () => {
|
||||
onZoomChange(Math.min(maxZoom, zoom + 5))
|
||||
}
|
||||
|
||||
const decreaseZoom = () => {
|
||||
onZoomChange(Math.max(minZoom, zoom - 5))
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Duration Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="duration" className="text-sm font-medium whitespace-nowrap">
|
||||
Duration:
|
||||
</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={decreaseDuration}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Input
|
||||
id="duration"
|
||||
type="number"
|
||||
min="10"
|
||||
max="600"
|
||||
step="0.1"
|
||||
value={durationInput}
|
||||
onChange={(e) => handleDurationInputChange(e.target.value)}
|
||||
onBlur={handleDurationInputBlur}
|
||||
className="h-8 w-16 text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={increaseDuration}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
seconds ({formatTime(duration / 1000)})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="zoom" className="text-sm font-medium whitespace-nowrap">
|
||||
Zoom:
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={decreaseZoom}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ZoomOut className="h-3 w-3" />
|
||||
</Button>
|
||||
<div className="w-32">
|
||||
<Slider
|
||||
id="zoom"
|
||||
min={minZoom}
|
||||
max={maxZoom}
|
||||
step={5}
|
||||
value={[zoom]}
|
||||
onValueChange={([value]) => onZoomChange(value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={increaseZoom}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ZoomIn className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{zoom}px/s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline Info */}
|
||||
<div className="flex items-center gap-4 ml-auto text-sm text-muted-foreground">
|
||||
<div>
|
||||
Total width: {Math.round((duration / 1000) * zoom)}px
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
src/components/sequencer/TrackControls.tsx
Normal file
124
src/components/sequencer/TrackControls.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { Track } from '@/pages/SequencerPage'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useState, forwardRef } from 'react'
|
||||
|
||||
interface TrackControlsProps {
|
||||
tracks: Track[]
|
||||
onAddTrack: () => void
|
||||
onRemoveTrack: (trackId: string) => void
|
||||
onUpdateTrackName: (trackId: string, name: string) => void
|
||||
onScroll?: () => void
|
||||
}
|
||||
|
||||
export const TrackControls = forwardRef<HTMLDivElement, TrackControlsProps>(({
|
||||
tracks,
|
||||
onAddTrack,
|
||||
onRemoveTrack,
|
||||
onUpdateTrackName,
|
||||
onScroll,
|
||||
}, ref) => {
|
||||
const [editingTrackId, setEditingTrackId] = useState<string | null>(null)
|
||||
const [editingName, setEditingName] = useState('')
|
||||
|
||||
const handleStartEditing = (track: Track) => {
|
||||
setEditingTrackId(track.id)
|
||||
setEditingName(track.name)
|
||||
}
|
||||
|
||||
const handleFinishEditing = () => {
|
||||
if (editingTrackId && editingName.trim()) {
|
||||
onUpdateTrackName(editingTrackId, editingName.trim())
|
||||
}
|
||||
setEditingTrackId(null)
|
||||
setEditingName('')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleFinishEditing()
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingTrackId(null)
|
||||
setEditingName('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header - matches time ruler height of h-8 (32px) */}
|
||||
<div className="h-8 px-3 border-b bg-muted/50 flex items-center justify-between flex-shrink-0">
|
||||
<h3 className="text-sm font-medium">Tracks</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAddTrack}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Track List */}
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:hidden"
|
||||
onScroll={onScroll}
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none', paddingBottom: '52px' }}
|
||||
>
|
||||
{tracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="flex items-center justify-between p-3 border-b hover:bg-muted/30 group"
|
||||
style={{ height: '80px' }} // Match track height in canvas
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingTrackId === track.id ? (
|
||||
<Input
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onBlur={handleFinishEditing}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="text-sm font-medium truncate cursor-pointer hover:bg-muted/50 p-1 rounded"
|
||||
onClick={() => handleStartEditing(track)}
|
||||
title={`Click to rename track: ${track.name}`}
|
||||
>
|
||||
{track.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{track.sounds.length} sound{track.sounds.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tracks.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemoveTrack(track.id)}
|
||||
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive"
|
||||
title="Remove track"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t bg-muted/50">
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{tracks.length} track{tracks.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
TrackControls.displayName = 'TrackControls'
|
||||
@@ -1,46 +1,108 @@
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Play, Clock, Weight } from 'lucide-react'
|
||||
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'
|
||||
import { formatSize } from '@/utils/format-size'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { Clock, Heart, Play, Weight } from 'lucide-react'
|
||||
|
||||
interface SoundCardProps {
|
||||
sound: Sound
|
||||
playSound: (sound: Sound) => void
|
||||
onFavoriteToggle: (soundId: number, isFavorited: boolean) => void
|
||||
colorClasses: string
|
||||
}
|
||||
|
||||
export function SoundCard({ sound, playSound, colorClasses }: SoundCardProps) {
|
||||
const handlePlaySound = () => {
|
||||
export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }: SoundCardProps) {
|
||||
const handlePlaySound = (e: React.MouseEvent) => {
|
||||
// Don't play sound if clicking on favorite button
|
||||
if ((e.target as HTMLElement).closest('[data-favorite-button]')) {
|
||||
return
|
||||
}
|
||||
playSound(sound)
|
||||
}
|
||||
|
||||
const handleFavoriteToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
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}
|
||||
className={cn(
|
||||
'py-2 transition-all duration-100 shadow-sm cursor-pointer active:scale-95',
|
||||
'py-2 transition-all duration-100 shadow-sm cursor-pointer active:scale-95 relative',
|
||||
colorClasses,
|
||||
)}
|
||||
>
|
||||
<CardContent className="grid grid-cols-1 pl-3 pr-3 gap-1">
|
||||
<h3 className="font-medium text-s truncate">{sound.name}</h3>
|
||||
<div className="grid grid-cols-3 gap-1 text-xs text-muted-foreground">
|
||||
{/* 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
|
||||
onClick={handleFavoriteToggle}
|
||||
className={cn(
|
||||
'absolute top-2 right-2 p-1 rounded-full transition-all duration-200 hover:scale-110',
|
||||
'bg-background/80 hover:bg-background/90 shadow-sm',
|
||||
sound.is_favorited
|
||||
? 'text-red-500 hover:text-red-600'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
title={sound.is_favorited ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Heart
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 transition-all duration-200',
|
||||
sound.is_favorited && 'fill-current'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<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" />
|
||||
<span>{formatDuration(sound.duration)}</span>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Weight className="h-3.5 w-3.5 mr-0.5" />
|
||||
<span>{formatSize(sound.size)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end items-center">
|
||||
<Play className="h-3.5 w-3.5 mr-0.5" />
|
||||
<NumberFlow value={sound.play_count} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show favorite count if > 0 */}
|
||||
<div className="grid grid-cols-2 gap-1 text-xs text-muted-foreground">
|
||||
<div className="flex">
|
||||
<Weight className="h-3.5 w-3.5 mr-0.5" />
|
||||
<span>{formatSize(sound.size)}</span>
|
||||
</div>
|
||||
<div className="flex justify-end items-center text-xs text-muted-foreground">
|
||||
<Heart className="h-3.5 w-3.5 mr-0.5" />
|
||||
<NumberFlow value={sound.favorite_count} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -9,14 +9,13 @@ const buttonVariants = cva(
|
||||
{
|
||||
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"
|
||||
|
||||
119
src/components/ui/combobox.tsx
Normal file
119
src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Check, ChevronsUpDown } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface ComboboxOption {
|
||||
value: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
searchValue?: string
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
value?: string
|
||||
onValueChange: (value: string) => void
|
||||
options: ComboboxOption[]
|
||||
placeholder?: string
|
||||
searchPlaceholder?: string
|
||||
emptyMessage?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
loadingMessage?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
placeholder = "Select option...",
|
||||
searchPlaceholder = "Search options...",
|
||||
emptyMessage = "No option found.",
|
||||
disabled = false,
|
||||
loading = false,
|
||||
loadingMessage = "Loading...",
|
||||
className,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const selectedOption = options.find((option) => option.value === value)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("w-full justify-between", className)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{selectedOption ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedOption.icon}
|
||||
<span>{selectedOption.label}</span>
|
||||
</div>
|
||||
) : loading ? (
|
||||
loadingMessage
|
||||
) : (
|
||||
placeholder
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0"
|
||||
style={{ WebkitOverflowScrolling: 'touch' } as React.CSSProperties}
|
||||
>
|
||||
<Command onWheel={(e) => e.stopPropagation()}>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandList
|
||||
className="max-h-[200px] overflow-y-auto overscroll-contain"
|
||||
style={{ touchAction: 'pan-y' }}
|
||||
>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.searchValue || `${option.value}-${option.label}`}
|
||||
onSelect={() => {
|
||||
onValueChange(option.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
87
src/components/ui/number-flow-duration.tsx
Normal file
87
src/components/ui/number-flow-duration.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { formatDuration } from '@/utils/format-duration'
|
||||
|
||||
interface NumberFlowDurationProps {
|
||||
duration: number
|
||||
variant?: 'clock' | 'wordy'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NumberFlowDuration({
|
||||
duration,
|
||||
variant = 'clock',
|
||||
className
|
||||
}: NumberFlowDurationProps) {
|
||||
const formatted = formatDuration(duration)
|
||||
|
||||
// Split the formatted duration to get individual parts
|
||||
const parts = formatted.split(':')
|
||||
|
||||
if (variant === 'clock') {
|
||||
// For clock variant
|
||||
if (parts.length === 2) {
|
||||
// Format: MM:SS
|
||||
const [minutes, seconds] = parts
|
||||
return (
|
||||
<span className={className}>
|
||||
<NumberFlow
|
||||
value={parseInt(minutes)}
|
||||
digits={{ 1: { max: 5 } }}
|
||||
format={{ minimumIntegerDigits: 2 }}
|
||||
/>
|
||||
<NumberFlow
|
||||
value={parseInt(seconds)}
|
||||
prefix=":"
|
||||
digits={{ 1: { max: 5 } }}
|
||||
format={{ minimumIntegerDigits: 2 }}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
} else {
|
||||
// Format: HH:MM:SS
|
||||
const [hours, minutes, seconds] = parts
|
||||
return (
|
||||
<span className={className}>
|
||||
<NumberFlow
|
||||
value={parseInt(hours)}
|
||||
format={{ minimumIntegerDigits: 2 }}
|
||||
/>
|
||||
<NumberFlow
|
||||
value={parseInt(minutes)}
|
||||
prefix=":"
|
||||
digits={{ 1: { max: 5 } }}
|
||||
format={{ minimumIntegerDigits: 2 }}
|
||||
/>
|
||||
<NumberFlow
|
||||
value={parseInt(seconds)}
|
||||
prefix=":"
|
||||
digits={{ 1: { max: 5 } }}
|
||||
format={{ minimumIntegerDigits: 2 }}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// For wordy variant
|
||||
if (parts.length === 2) {
|
||||
// Format: MM:SS
|
||||
const [minutes, seconds] = parts
|
||||
return (
|
||||
<span className={className}>
|
||||
<NumberFlow value={parseInt(minutes)} />m{' '}
|
||||
<NumberFlow value={parseInt(seconds)} />s
|
||||
</span>
|
||||
)
|
||||
} else {
|
||||
// Format: HH:MM:SS
|
||||
const [hours, minutes, seconds] = parts
|
||||
return (
|
||||
<span className={className}>
|
||||
<NumberFlow value={parseInt(hours)} />h{' '}
|
||||
<NumberFlow value={parseInt(minutes)} />m{' '}
|
||||
<NumberFlow value={parseInt(seconds)} />s
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
22
src/components/ui/number-flow-size.tsx
Normal file
22
src/components/ui/number-flow-size.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { formatSizeObject } from '@/utils/format-size'
|
||||
|
||||
interface NumberFlowSizeProps {
|
||||
size: number
|
||||
binary?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NumberFlowSize({
|
||||
size,
|
||||
binary = true,
|
||||
className
|
||||
}: NumberFlowSizeProps) {
|
||||
const sizeObj = formatSizeObject(size, binary)
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
<NumberFlow value={sizeObj.value} /> {sizeObj.unit}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import type { AuthContextType, User, LoginRequest, RegisterRequest } from '@/types/auth'
|
||||
import { authEvents, AUTH_EVENTS } from '@/lib/events'
|
||||
import { AUTH_EVENTS, authEvents } from '@/lib/events'
|
||||
import { tokenRefreshManager } from '@/lib/token-refresh-manager'
|
||||
import type {
|
||||
AuthContextType,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
User,
|
||||
} from '@/types/auth'
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
|
||||
21
src/contexts/LocaleContext.tsx
Normal file
21
src/contexts/LocaleContext.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
type Locale = 'en-US' | 'fr-FR'
|
||||
|
||||
type LocaleProviderState = {
|
||||
locale: Locale
|
||||
timezone: string
|
||||
setLocale: (locale: Locale) => void
|
||||
setTimezone: (timezone: string) => void
|
||||
}
|
||||
|
||||
const initialState: LocaleProviderState = {
|
||||
locale: 'fr-FR',
|
||||
timezone: 'Europe/Paris',
|
||||
setLocale: () => null,
|
||||
setTimezone: () => null,
|
||||
}
|
||||
|
||||
export const LocaleProviderContext =
|
||||
createContext<LocaleProviderState>(initialState)
|
||||
export type { Locale, LocaleProviderState }
|
||||
@@ -1,8 +1,28 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Socket, io } from 'socket.io-client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AUTH_EVENTS,
|
||||
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'
|
||||
import { useAuth } from './AuthContext'
|
||||
import { authEvents, AUTH_EVENTS, soundEvents, SOUND_EVENTS, userEvents, USER_EVENTS, playerEvents, PLAYER_EVENTS } from '../lib/events'
|
||||
|
||||
interface SocketContextType {
|
||||
socket: Socket | null
|
||||
@@ -24,13 +44,29 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null)
|
||||
const [isReconnecting, setIsReconnecting] = useState(false)
|
||||
|
||||
const fetchAndShowOngoingExtractions = useCallback(async () => {
|
||||
try {
|
||||
const processingExtractions = await extractionsService.getProcessingExtractions()
|
||||
|
||||
processingExtractions.forEach(extraction => {
|
||||
const title = extraction.title || 'Processing extraction...'
|
||||
toast.loading(`Extracting: ${title}`, {
|
||||
id: `extraction-${extraction.id}`,
|
||||
duration: Infinity, // Keep it open until status changes
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch ongoing extractions:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const createSocket = useCallback(() => {
|
||||
if (!user) return null
|
||||
|
||||
// Get socket URL - use relative URL in production with reverse proxy
|
||||
const socketUrl = import.meta.env.PROD
|
||||
? '' // Use relative URL in production (same origin as frontend)
|
||||
: (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000')
|
||||
: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
|
||||
|
||||
const newSocket = io(socketUrl, {
|
||||
withCredentials: true,
|
||||
@@ -44,48 +80,110 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
||||
setIsConnected(true)
|
||||
setConnectionError(null)
|
||||
setIsReconnecting(false)
|
||||
|
||||
// Fetch and show any ongoing extractions
|
||||
fetchAndShowOngoingExtractions()
|
||||
})
|
||||
|
||||
newSocket.on('disconnect', () => {
|
||||
setIsConnected(false)
|
||||
})
|
||||
|
||||
newSocket.on('connect_error', (error) => {
|
||||
newSocket.on('connect_error', error => {
|
||||
setConnectionError(`Connection failed: ${error.message}`)
|
||||
setIsConnected(false)
|
||||
setIsReconnecting(false)
|
||||
})
|
||||
|
||||
// Listen for message events
|
||||
newSocket.on('user_message', (data) => {
|
||||
newSocket.on('user_message', data => {
|
||||
toast.info(`Message from ${data.from_user_name}`, {
|
||||
description: data.message,
|
||||
})
|
||||
})
|
||||
|
||||
newSocket.on('broadcast_message', (data) => {
|
||||
newSocket.on('broadcast_message', data => {
|
||||
toast.warning(`Broadcast from ${data.from_user_name}`, {
|
||||
description: data.message,
|
||||
})
|
||||
})
|
||||
|
||||
// Listen for player events and emit them locally
|
||||
newSocket.on('player_state', (data) => {
|
||||
newSocket.on('player_state', data => {
|
||||
playerEvents.emit(PLAYER_EVENTS.PLAYER_STATE, data)
|
||||
})
|
||||
|
||||
// Listen for sound events and emit them locally
|
||||
newSocket.on('sound_played', (data) => {
|
||||
newSocket.on('sound_played', data => {
|
||||
soundEvents.emit(SOUND_EVENTS.SOUND_PLAYED, data)
|
||||
})
|
||||
|
||||
newSocket.on('sound_favorited', data => {
|
||||
soundEvents.emit(SOUND_EVENTS.SOUND_FAVORITED, data)
|
||||
})
|
||||
|
||||
// Listen for user events and emit them locally
|
||||
newSocket.on('user_credits_changed', (data) => {
|
||||
newSocket.on('user_credits_changed', data => {
|
||||
userEvents.emit(USER_EVENTS.USER_CREDITS_CHANGED, data)
|
||||
})
|
||||
|
||||
// Listen for extraction status updates
|
||||
newSocket.on('extraction_status_update', data => {
|
||||
const { extraction_id, status, title, error } = data
|
||||
|
||||
// Emit local event for other components to listen to
|
||||
extractionEvents.emit(EXTRACTION_EVENTS.EXTRACTION_STATUS_UPDATED, data)
|
||||
|
||||
// Handle specific status events
|
||||
switch (status) {
|
||||
case 'processing':
|
||||
toast.loading(`Extracting: ${title}`, {
|
||||
id: `extraction-${extraction_id}`,
|
||||
duration: Infinity, // Keep it open until status changes
|
||||
})
|
||||
break
|
||||
case 'completed':
|
||||
toast.dismiss(`extraction-${extraction_id}`)
|
||||
toast.success(`Extraction complete: ${title}`, {
|
||||
duration: 4000,
|
||||
})
|
||||
extractionEvents.emit(EXTRACTION_EVENTS.EXTRACTION_COMPLETED, data)
|
||||
break
|
||||
case 'failed':
|
||||
toast.dismiss(`extraction-${extraction_id}`)
|
||||
toast.error(`Extraction failed: ${title}`, {
|
||||
description: error,
|
||||
duration: 6000,
|
||||
})
|
||||
extractionEvents.emit(EXTRACTION_EVENTS.EXTRACTION_FAILED, data)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// 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])
|
||||
}, [user, fetchAndShowOngoingExtractions])
|
||||
|
||||
// Handle token refresh - reconnect socket with new token
|
||||
const handleTokenRefresh = useCallback(() => {
|
||||
@@ -112,7 +210,6 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
||||
}
|
||||
}, [handleTokenRefresh])
|
||||
|
||||
|
||||
// Initial socket connection
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
@@ -146,9 +243,7 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,5 +12,6 @@ const initialState: ThemeProviderState = {
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
export const ThemeProviderContext =
|
||||
createContext<ThemeProviderState>(initialState)
|
||||
export type { Theme, ThemeProviderState }
|
||||
11
src/hooks/use-locale.ts
Normal file
11
src/hooks/use-locale.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { LocaleProviderContext } from '@/contexts/LocaleContext'
|
||||
import { useContext } from 'react'
|
||||
|
||||
export const useLocale = () => {
|
||||
const context = useContext(LocaleProviderContext)
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error('useLocale must be used within a LocaleProvider')
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useContext } from 'react'
|
||||
import { ThemeProviderContext } from '@/contexts/ThemeContext'
|
||||
import { useContext } from 'react'
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
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,
|
||||
}), [])
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user