Compare commits

..

68 Commits

Author SHA1 Message Date
JSC
86a04cf5cb feat: add README file to provide project documentation
Some checks failed
Frontend CI / lint (push) Failing after 22s
Frontend CI / build (push) Has been skipped
2025-10-05 16:29:07 +02:00
JSC
0a2b859e7a feat: add border to Play Next Queue section for improved visual separation 2025-10-05 04:09:29 +02:00
JSC
7a6288cc02 feat: update PlayNextQueue component and integrate it into Player; adjust layout in Playlist for improved UI
Some checks failed
Frontend CI / lint (push) Failing after 18s
Frontend CI / build (push) Has been skipped
2025-10-04 21:55:20 +02:00
JSC
0f8b96e73c feat: add PlayNextQueue component to display upcoming tracks in Player 2025-10-04 19:40:01 +02:00
JSC
9a2a9343d2 feat: add context menu to Playlist for adding tracks to play next queue 2025-10-04 19:16:25 +02:00
JSC
4352e4c792 feat: implement lazy loading for routes and add a loading component in App.tsx; configure manual chunks in Vite for optimized builds
Some checks failed
Frontend CI / lint (push) Failing after 18s
Frontend CI / build (push) Has been skipped
2025-10-04 15:44:33 +02:00
JSC
b303d2f5cf feat: add search functionality to filter available sounds in PlaylistEditPage
Some checks failed
Frontend CI / lint (push) Failing after 21s
Frontend CI / build (push) Has been skipped
2025-10-04 15:29:32 +02:00
JSC
9aa628a9d2 refactor: standardize "use client" directive and improve button styles across components
Some checks failed
Frontend CI / lint (push) Failing after 18s
Frontend CI / build (push) Has been skipped
2025-10-04 14:57:18 +02:00
JSC
cbd4b93fd4 chore: update recharts dependency and refactor chart components
Some checks failed
Frontend CI / lint (push) Failing after 21s
Frontend CI / build (push) Has been skipped
- Updated recharts version from 3.2.1 to 2.15.4 in package.json.
- Removed unused imports and types in GlobalSearch component.
- Adjusted spacing in CardHeader component.
- Refactored ChartTooltipContent and ChartLegendContent components to filter out items with type 'none' and improve readability.
2025-10-04 14:50:40 +02:00
JSC
7d94e29dcb feat: add global escape key handling to close search modal
Some checks failed
Frontend CI / lint (push) Failing after 17s
Frontend CI / build (push) Has been skipped
2025-10-04 14:41:59 +02:00
JSC
adf16210a4 feat: implement global search functionality with keyboard shortcut and results display
Some checks failed
Frontend CI / lint (push) Failing after 19s
Frontend CI / build (push) Has been skipped
2025-10-04 14:39:04 +02:00
JSC
adcf9832f4 feat: add search functionality to Playlist component for filtering tracks
Some checks failed
Frontend CI / lint (push) Failing after 18s
Frontend CI / build (push) Has been skipped
2025-10-04 14:09:33 +02:00
JSC
53a39126f4 chore: update dependencies to latest versions
Some checks failed
Frontend CI / lint (push) Failing after 19s
Frontend CI / build (push) Has been skipped
- Updated @radix-ui/react-checkbox, @radix-ui/react-context-menu, @radix-ui/react-dialog, @radix-ui/react-dropdown-menu, @radix-ui/react-popover, @radix-ui/react-scroll-area, @radix-ui/react-select, @radix-ui/react-slider, @radix-ui/react-switch, @radix-ui/react-tabs, @radix-ui/react-tooltip, and @tailwindcss/vite to their latest versions.
- Updated lucide-react, react, react-day-picker, react-dom, react-router, and recharts to their latest versions.
- Updated devDependencies: @eslint/js, @types/node, @types/react, @types/react-dom, @vitejs/plugin-react-swc, eslint, eslint-plugin-react-hooks, eslint-plugin-react-refresh, globals, tw-animate-css, typescript, typescript-eslint, and vite to their latest versions.
2025-10-04 13:55:20 +02:00
JSC
f43fec3362 feat: add TopUsersSection component to DashboardPage for displaying top users
Some checks failed
Frontend CI / lint (push) Failing after 19s
Frontend CI / build (push) Has been skipped
2025-09-27 21:52:10 +02:00
JSC
d17dc5558c feat: add TTS statistics to DashboardPage and StatisticsGrid components 2025-09-27 21:38:07 +02:00
JSC
79d9bd7f76 feat: update SoundCard styling and adjust layout in SoundsPage for better responsiveness
Some checks failed
Frontend CI / lint (push) Failing after 18s
Frontend CI / build (push) Has been skipped
2025-09-22 20:43:09 +02:00
JSC
756e1307f1 Merge branch 'optimised_player'
Some checks failed
Frontend CI / lint (push) Failing after 18s
Frontend CI / build (push) Has been skipped
2025-09-22 20:30:59 +02:00
JSC
a25fb9b5eb feat: implement new compact player components for controls, progress, and track info 2025-09-22 20:28:27 +02:00
JSC
9db9915e56 feat: remove useRenderFlash hook and clean up related code in Player components 2025-09-22 20:14:38 +02:00
JSC
9366dbca14 feat: integrate volume control into PlayerControls and remove PlayerVolume component 2025-09-22 20:10:37 +02:00
JSC
9784d259ba feat: refactor player component structure and introduce new player controls, progress, track info, and volume components 2025-09-22 19:47:13 +02:00
JSC
b77dff03c1 feat: optimize player state updates and memoize calculations to prevent unnecessary re-renders
Some checks failed
Frontend CI / lint (push) Failing after 18s
Frontend CI / build (push) Has been skipped
2025-09-21 20:32:29 +02:00
JSC
8945eb6ad6 feat: remove task update functionality from SchedulersTable and clean up related code
Some checks failed
Frontend CI / lint (push) Failing after 18s
Frontend CI / build (push) Has been skipped
2025-09-21 18:48:05 +02:00
JSC
dc1124dff6 feat: add delete functionality for playlists with confirmation and error handling
Some checks failed
Frontend CI / lint (push) Failing after 18s
Frontend CI / build (push) Has been skipped
2025-09-21 18:32:33 +02:00
JSC
d551223566 Merge branch 'tts'
Some checks failed
Frontend CI / lint (push) Failing after 19s
Frontend CI / build (push) Has been skipped
2025-09-21 18:18:57 +02:00
JSC
1d2c27abbd feat: enhance loading states with table structure and improve sound type filtering in SoundsPage 2025-09-21 18:17:55 +02:00
JSC
41659c9299 feat: add badge to display sound type in SoundCard component 2025-09-21 15:39:04 +02:00
JSC
7faf2d38ab feat: add language selection combobox and update TTS dialog for improved language handling 2025-09-21 15:20:12 +02:00
JSC
7ac979a4f4 refactor: remove unnecessary reset of selected provider in CreateTTSDialog 2025-09-21 14:43:27 +02:00
JSC
92846c6d3a feat: implement TTS event handling for creation, completion, and failure in TTSPage and SocketContext 2025-09-21 14:39:15 +02:00
JSC
75b52caf85 refactor: update TASK_TYPES to comment out 'credit_recharge' in CreateTaskDialog and SchedulersHeader
feat: add filter icon to SelectTrigger in SoundsPage for improved UI
2025-09-21 13:56:50 +02:00
JSC
fde19f47c8 refactor: update TTSTable component to improve date formatting and remove Sound ID display 2025-09-21 13:40:56 +02:00
JSC
3516f7f205 fix: update TTS API endpoints for consistency in request and history retrieval 2025-09-21 13:38:05 +02:00
JSC
d48291f6ed feat: add type filter to SoundsPage for improved sound categorization 2025-09-21 13:31:19 +02:00
JSC
620418c405 refactor: update TTSHeader component for improved sorting and search functionality 2025-09-21 13:21:02 +02:00
JSC
6f477a1aa7 feat: add Text to Speech (TTS) functionality with provider selection, generation, and history management
- Implemented CreateTTSDialog for generating TTS from user input.
- Added TTSHeader for search, sorting, and creation controls.
- Created TTSList to display TTS history with filtering and sorting capabilities.
- Developed TTSLoadingStates for handling loading and error states.
- Introduced TTSRow for individual TTS entries with play and delete options.
- Built TTSTable for structured display of TTS history.
- Integrated TTS service API for generating and managing TTS data.
- Added TTSPage to encapsulate the TTS feature with pagination and state management.
2025-09-20 23:11:21 +02:00
JSC
da4566c789 feat: add 'Stop All Sounds' button to Player component for improved sound control
Some checks failed
Frontend CI / lint (push) Failing after 20s
Frontend CI / build (push) Has been skipped
2025-09-20 15:50:47 +02:00
JSC
7c3a8aab64 fix: add cursor pointer style to StopSoundsButton for better UX
Some checks failed
Frontend CI / lint (push) Failing after 19s
Frontend CI / build (push) Has been skipped
2025-09-20 15:41:07 +02:00
JSC
72914da637 feat: add Dailymotion badge and improve extraction title display in ExtractionsRow
Some checks failed
Frontend CI / lint (push) Failing after 17s
Frontend CI / build (push) Has been skipped
2025-09-20 15:33:10 +02:00
JSC
70131ecd2d feat: add status filter to ExtractionsHeader and update SchedulersHeader layout
Some checks failed
Frontend CI / lint (push) Failing after 22s
Frontend CI / build (push) Has been skipped
2025-09-20 15:03:36 +02:00
JSC
f559a6aa73 fix: remove unused task type 'credit_recharge' from CreateTaskDialog
Some checks failed
Frontend CI / lint (push) Failing after 17s
Frontend CI / build (push) Has been skipped
2025-09-19 11:54:54 +02:00
JSC
b024b19ecc fix: update Toaster position and mark Sequencer link as WIP
Some checks failed
Frontend CI / lint (push) Failing after 18s
Frontend CI / build (push) Has been skipped
2025-09-18 13:57:43 +02:00
JSC
2281993edb Merge branch 'sequencer2'
Some checks failed
Frontend CI / lint (push) Failing after 18s
Frontend CI / build (push) Has been skipped
2025-09-17 18:31:18 +02:00
JSC
4fe9251a2d fix: update task cancellation messages to reflect deletion action 2025-09-16 13:45:25 +02:00
JSC
4fe280cf5c feat: add 'minutely' option to recurrence types in CreateTaskDialog and schedulers 2025-09-13 23:44:08 +02:00
JSC
24cc0cc45f fix: add cursor pointer style to button variants for better UX 2025-09-13 22:50:47 +02:00
JSC
f7bfd3de73 Merge branch 'main' into sequencer2 2025-09-13 22:42:29 +02:00
JSC
2babeba49e feat: implement 100ms snapping for sound placement and enhance zoom controls in Sequencer 2025-09-13 22:29:37 +02:00
JSC
92444fb023 feat: enhance time snapping and interval calculation for improved sound placement in Sequencer 2025-09-03 21:35:42 +02:00
JSC
cd7af24831 feat: implement time snapping to 100ms intervals for improved sound placement accuracy 2025-09-03 21:03:28 +02:00
JSC
d4b87aafe3 feat: enhance time interval calculation for zoom level in SequencerCanvas 2025-09-03 20:51:14 +02:00
JSC
37c932fe75 feat: convert duration and startTime to milliseconds in Sequencer components for consistency 2025-09-03 20:22:59 +02:00
JSC
1ba6f23999 Improves sound placement and preview logic
Refines the sound placement logic in the sequencer to ensure sounds
are placed correctly within track boundaries. It restricts sound
placement to the track duration, preventing sounds from being placed
out of bounds.

Enhances the drag preview by visually indicating invalid placement
positions with a red border and "Invalid" label.

Also extracts duration and size formatting into separate utility functions
for better code organization.
2025-09-03 17:17:19 +02:00
JSC
dba08e2ec0 feat: implement sound removal functionality in SequencerPage and update SequencerCanvas props 2025-09-03 17:03:04 +02:00
JSC
aa11ec379d feat: add DragOverlay to SequencerPage for improved drag-and-drop feedback 2025-09-03 16:57:55 +02:00
JSC
7982a2eb6d feat: update DraggableSound component to use break-words for sound name display 2025-09-03 16:55:21 +02:00
JSC
5afb761d3c feat: refactor fetchSounds to use useCallback and remove mock data for improved API integration 2025-09-03 16:52:14 +02:00
JSC
9603daa5ce refactor: remove noPadding prop from AppLayout and simplify class names in SequencerCanvas 2025-09-03 16:45:34 +02:00
JSC
2ec58ea268 feat: update link text in SequencerPage header from "Home" to "Dashboard" 2025-09-03 16:39:22 +02:00
JSC
df60b5ce93 feat: enhance AppLayout and SequencerPage for improved layout and responsiveness 2025-09-03 16:32:05 +02:00
JSC
80a18575a1 feat: update SequencerPage layout for full height and improved responsiveness 2025-09-03 15:55:35 +02:00
JSC
74dfec2e29 feat: add Sequencer navigation item to AppSidebar and wrap SequencerPage in AppLayout for improved structure 2025-09-03 15:30:46 +02:00
JSC
282ba9446d feat: reduce width of TrackControls for improved layout 2025-09-03 15:20:46 +02:00
JSC
d7b1d97a28 feat: add padding to bottom of SequencerCanvas and TrackControls for improved layout 2025-09-03 15:17:52 +02:00
JSC
7e03189fc4 feat: adjust styling of PlacedSoundItem for improved positioning and visual consistency 2025-09-03 15:11:23 +02:00
JSC
a0d5840166 feat: improve layout of SequencerCanvas and SequencerPage for better responsiveness and overflow handling 2025-09-03 15:06:38 +02:00
JSC
25eacbc85f feat: enhance SequencerPage and SequencerCanvas with drag-and-drop functionality for sound placement and improved track management 2025-09-03 14:46:28 +02:00
JSC
28faf9b149 feat: add SequencerPage with sequencer functionality including track and sound management
feat: implement SequencerCanvas for visualizing tracks and placed sounds
feat: create SoundLibrary for draggable sound selection
feat: add TimelineControls for managing duration and zoom levels
feat: implement TrackControls for adding, removing, and renaming tracks
2025-09-03 00:23:59 +02:00
70 changed files with 5774 additions and 1124 deletions

0
README.md Normal file
View File

360
bun.lock
View File

@@ -9,55 +9,55 @@
"@dnd-kit/utilities": "^3.2.2",
"@number-flow/react": "^0.5.10",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.539.0",
"lucide-react": "^0.544.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-day-picker": "^9.8.1",
"react-dom": "^19.1.0",
"react-router": "^7.7.1",
"react": "^19.2.0",
"react-day-picker": "^9.11.0",
"react-dom": "^19.2.0",
"react-router": "^7.9.3",
"recharts": "2.15.4",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tailwindcss": "^4.1.14",
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@eslint/js": "^9.37.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/node": "^24.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react-swc": "^3.10.2",
"@types/node": "^24.6.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react-swc": "^4.1.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"eslint": "^9.37.0",
"eslint-plugin-react-hooks": "^6.1.1",
"eslint-plugin-react-refresh": "^0.4.23",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"tw-animate-css": "^1.3.6",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.9",
"vitest": "^3.2.4",
},
},
@@ -67,17 +67,31 @@
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.4", "", {}, "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw=="],
"@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="],
"@babel/generator": ["@babel/generator@7.28.0", "", { "dependencies": { "@babel/parser": "^7.28.0", "@babel/types": "^7.28.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
"@babel/parser": ["@babel/parser@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="],
"@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="],
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
@@ -87,7 +101,7 @@
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
"@date-fns/tz": ["@date-fns/tz@1.2.0", "", {}, "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg=="],
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
@@ -149,23 +163,23 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
"@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.3.0", "", {}, "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0" } }, "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog=="],
"@eslint/core": ["@eslint/core@0.15.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA=="],
"@eslint/core": ["@eslint/core@0.16.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
"@eslint/js": ["@eslint/js@9.32.0", "", {}, "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg=="],
"@eslint/js": ["@eslint/js@9.37.0", "", {}, "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.4", "", { "dependencies": { "@eslint/core": "^0.15.1", "levn": "^0.4.1" } }, "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0", "levn": "^0.4.1" } }, "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A=="],
"@floating-ui/core": ["@floating-ui/core@1.7.2", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw=="],
@@ -191,6 +205,8 @@
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="],
@@ -209,13 +225,13 @@
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
@@ -223,17 +239,17 @@
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsQUMjcYTsBjTSXw0P3GO0werEQvUY2plgRQuKoCTtkNr45q1DiL51j4m7gxhABzZ0BadoXNsIbg7F3KwiUBbw=="],
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
@@ -241,37 +257,37 @@
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
@@ -295,7 +311,7 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.35", "", {}, "sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.45.1", "", { "os": "android", "cpu": "arm" }, "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA=="],
@@ -339,61 +355,61 @@
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@swc/core": ["@swc/core@1.13.2", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.23" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.13.2", "@swc/core-darwin-x64": "1.13.2", "@swc/core-linux-arm-gnueabihf": "1.13.2", "@swc/core-linux-arm64-gnu": "1.13.2", "@swc/core-linux-arm64-musl": "1.13.2", "@swc/core-linux-x64-gnu": "1.13.2", "@swc/core-linux-x64-musl": "1.13.2", "@swc/core-win32-arm64-msvc": "1.13.2", "@swc/core-win32-ia32-msvc": "1.13.2", "@swc/core-win32-x64-msvc": "1.13.2" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-YWqn+0IKXDhqVLKoac4v2tV6hJqB/wOh8/Br8zjqeqBkKa77Qb0Kw2i7LOFzjFNZbZaPH6AlMGlBwNrxaauaAg=="],
"@swc/core": ["@swc/core@1.13.5", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.24" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.13.5", "@swc/core-darwin-x64": "1.13.5", "@swc/core-linux-arm-gnueabihf": "1.13.5", "@swc/core-linux-arm64-gnu": "1.13.5", "@swc/core-linux-arm64-musl": "1.13.5", "@swc/core-linux-x64-gnu": "1.13.5", "@swc/core-linux-x64-musl": "1.13.5", "@swc/core-win32-arm64-msvc": "1.13.5", "@swc/core-win32-ia32-msvc": "1.13.5", "@swc/core-win32-x64-msvc": "1.13.5" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.13.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-44p7ivuLSGFJ15Vly4ivLJjg3ARo4879LtEBAabcHhSZygpmkP8eyjyWxrH3OxkY1eRZSIJe8yRZPFw4kPXFPw=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.13.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.13.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Lb9EZi7X2XDAVmuUlBm2UvVAgSCbD3qKqDCxSI4jEOddzVOpNCnyZ/xEampdngUIyDDhhJLYU9duC+Mcsv5Y+A=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.13.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.13.2", "", { "os": "linux", "cpu": "arm" }, "sha512-9TDe/92ee1x57x+0OqL1huG4BeljVx0nWW4QOOxp8CCK67Rpc/HHl2wciJ0Kl9Dxf2NvpNtkPvqj9+BUmM9WVA=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.13.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.13.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJUSl56DBk7AWMAIEcU83zl5mg3vlQYhLELhjwRFkGFMvghQvdqQ3zFOYa4TexKA7noBZa3C8fb24rI5sw9Exg=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.13.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-teU27iG1oyWpNh9CzcGQ48ClDRt/RCem7mYO7ehd2FY102UeTws2+OzLESS1TS1tEZipq/5xwx3FzbVgiolCiQ=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.13.2", "", { "os": "linux", "cpu": "x64" }, "sha512-dRPsyPyqpLD0HMRCRpYALIh4kdOir8pPg4AhNQZLehKowigRd30RcLXGNVZcc31Ua8CiPI4QSgjOIxK+EQe4LQ=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.13.2", "", { "os": "linux", "cpu": "x64" }, "sha512-CCxETW+KkYEQDqz1SYC15YIWYheqFC+PJVOW76Maa/8yu8Biw+HTAcblKf2isrlUtK8RvrQN94v3UXkC2NzCEw=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.13.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wv/QTA6PjyRLlmKcN6AmSI4jwSMRl0VTLGs57PHTqYRwwfwd7y4s2fIPJVBNbAlXd795dOEP6d/bGSQSyhOX3A=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.13.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.13.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-PuCdtNynEkUNbUXX/wsyUC+t4mamIU5y00lT5vJcAvco3/r16Iaxl5UCzhXYaWZSNVZMzPp9qN8NlSL8M5pPxw=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.13.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.13.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qlmMkFZJus8cYuBURx1a3YAG2G7IW44i+FEYV5/32ylKkzGNAr9tDJSA53XNnNXkAB5EXSPsOz7bn5C3JlEtdQ=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.13.5", "", { "os": "win32", "cpu": "x64" }, "sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q=="],
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/types": ["@swc/types@0.1.23", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw=="],
"@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.1", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.14" } }, "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.11", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.11", "@tailwindcss/oxide-darwin-arm64": "4.1.11", "@tailwindcss/oxide-darwin-x64": "4.1.11", "@tailwindcss/oxide-freebsd-x64": "4.1.11", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", "@tailwindcss/oxide-linux-x64-musl": "4.1.11", "@tailwindcss/oxide-wasm32-wasi": "4.1.11", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" } }, "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.14", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.5.1" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.14", "@tailwindcss/oxide-darwin-arm64": "4.1.14", "@tailwindcss/oxide-darwin-x64": "4.1.14", "@tailwindcss/oxide-freebsd-x64": "4.1.14", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14", "@tailwindcss/oxide-linux-arm64-musl": "4.1.14", "@tailwindcss/oxide-linux-x64-gnu": "4.1.14", "@tailwindcss/oxide-linux-x64-musl": "4.1.14", "@tailwindcss/oxide-wasm32-wasi": "4.1.14", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14", "@tailwindcss/oxide-win32-x64-msvc": "4.1.14" } }, "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.11", "", { "os": "android", "cpu": "arm64" }, "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.14", "", { "os": "android", "cpu": "arm64" }, "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11", "", { "os": "linux", "cpu": "arm" }, "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14", "", { "os": "linux", "cpu": "arm" }, "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.11", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.14", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.5", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.11", "", { "os": "win32", "cpu": "x64" }, "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.14", "", { "os": "win32", "cpu": "x64" }, "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.14", "", { "dependencies": { "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "tailwindcss": "4.1.14" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA=="],
"@trivago/prettier-plugin-sort-imports": ["@trivago/prettier-plugin-sort-imports@5.2.2", "", { "dependencies": { "@babel/generator": "^7.26.5", "@babel/parser": "^7.26.7", "@babel/traverse": "^7.26.7", "@babel/types": "^7.26.7", "javascript-natural-sort": "^0.7.1", "lodash": "^4.17.21" }, "peerDependencies": { "@vue/compiler-sfc": "3.x", "prettier": "2.x - 3.x", "prettier-plugin-svelte": "3.x", "svelte": "4.x || 5.x" }, "optionalPeers": ["@vue/compiler-sfc", "prettier-plugin-svelte", "svelte"] }, "sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA=="],
@@ -423,33 +439,33 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
"@types/node": ["@types/node@24.6.2", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
"@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="],
"@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="],
"@types/react-dom": ["@types/react-dom@19.2.0", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.38.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/type-utils": "8.38.0", "@typescript-eslint/utils": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.38.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.45.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/type-utils": "8.45.0", "@typescript-eslint/utils": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.45.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.38.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.45.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.38.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.38.0", "@typescript-eslint/types": "^8.38.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.45.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.45.0", "@typescript-eslint/types": "^8.45.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0" } }, "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0" } }, "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.38.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.45.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/utils": "8.38.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0", "@typescript-eslint/utils": "8.45.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.38.0", "", {}, "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.45.0", "", {}, "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.38.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.38.0", "@typescript-eslint/tsconfig-utils": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.45.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.45.0", "@typescript-eslint/tsconfig-utils": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.38.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.45.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag=="],
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.11.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.27", "@swc/core": "^1.12.11" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w=="],
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@4.1.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.35", "@swc/core": "^1.13.5" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-Ff690TUck0Anlh7wdIcnsVMhofeEVgm44Y4OYdeeEEPSKyZHzDI9gfVBvySEhDfXtBp8tLCbfsVKPWEMEjq8/g=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "ast-v8-to-istanbul": "^0.3.3", "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.2.4", "vitest": "3.2.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="],
@@ -487,14 +503,20 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-i+sRXGhz4+QW8aACZ3+r1GAKMt0wlFpeA8M5rOQd0HEYw9zhDrlx9Wc8uQ0IdXakjJRthzglEwfB/yqIjO6iDg=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.26.3", "", { "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", "electron-to-chromium": "^1.5.227", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001747", "", {}, "sha512-mzFa2DGIhuc5490Nd/G31xN1pnBnYMadtkyTjefPI7wzypqgCEpeWu9bJr0OnDsyKrW75zA9ZAt7pbQFmwLsQg=="],
"chai": ["chai@5.2.1", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -515,6 +537,8 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -563,25 +587,29 @@
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.230", "", {}, "sha512-A6A6Fd3+gMdaed9wX83CvHYJb4UuapPD5X5SLq72VZJzxHSY0/LUweGXRWmQlh2ln7KV7iw7jnwXK7dlPoOnHQ=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"engine.io-client": ["engine.io-client@6.6.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w=="],
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
"enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.32.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.0", "@eslint/core": "^0.15.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.32.0", "@eslint/plugin-kit": "^0.3.4", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg=="],
"eslint": ["eslint@9.37.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.4.0", "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.37.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@6.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "zod": "^3.22.4 || ^4.0.0", "zod-validation-error": "^3.0.3 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-St9EKZzOAQF704nt2oJvAKZHjhrpg25ClQoaAlHmPZuajFldVLqRDW4VBNAS01NzeiQF0m0qhG1ZA807K6aVaQ=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.20", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.23", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
@@ -607,7 +635,7 @@
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="],
"fast-equals": ["fast-equals@5.3.2", "", {}, "sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -617,7 +645,7 @@
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
"fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
@@ -633,13 +661,15 @@
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.3.0", "", {}, "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ=="],
"globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -693,6 +723,8 @@
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
@@ -729,9 +761,9 @@
"loupe": ["loupe@3.2.0", "", {}, "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.539.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg=="],
"lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
@@ -747,9 +779,7 @@
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -759,6 +789,8 @@
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"node-releases": ["node-releases@2.0.23", "", {}, "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg=="],
"number-flow": ["number-flow@0.5.8", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-FPr1DumWyGi5Nucoug14bC6xEz70A1TnhgSHhKyfqjgji2SOTz+iLJxKtv37N5JyJbteGYCm6NQ9p1O4KZ7iiA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -799,11 +831,11 @@
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
"react-day-picker": ["react-day-picker@9.8.1", "", { "dependencies": { "@date-fns/tz": "^1.2.0", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-kMcLrp3PfN/asVJayVv82IjF3iLOOxuH5TNFWezX6lS/T8iVRFPTETpHl3TUSTH99IDMZLubdNPJr++rQctkEw=="],
"react-day-picker": ["react-day-picker@9.11.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-L4FYOaPrr3+AEROeP6IG2mCORZZfxJDkJI2df8mv1jyPrNYeccgmFPZDaHyAuPCBCddQFozkxbikj2NhMEYfDQ=="],
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
@@ -811,7 +843,7 @@
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-router": ["react-router@7.7.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-jVKHXoWRIsD/qS6lvGveckwb862EekvapdHJN/cGmzw40KnJH5gg53ujOJ4qX6EKIK9LSBfFed/xiQ5yeXNrUA=="],
"react-router": ["react-router@7.9.3", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg=="],
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
@@ -831,9 +863,9 @@
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
@@ -873,11 +905,11 @@
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
"tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="],
"tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
"tar": ["tar@7.5.1", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g=="],
"test-exclude": ["test-exclude@7.0.1", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^9.0.4" } }, "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg=="],
@@ -887,7 +919,7 @@
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
@@ -901,15 +933,17 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.3.6", "", {}, "sha512-9dy0R9UsYEGmgf26L8UcHiLmSFTHa9+D7+dAt/G/sF5dCnPePZbfgDYinc7/UzAM7g/baVrmS6m9yEpU46d+LA=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.38.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.38.0", "@typescript-eslint/parser": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/utils": "8.38.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg=="],
"typescript-eslint": ["typescript-eslint@8.45.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.45.0", "@typescript-eslint/parser": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0", "@typescript-eslint/utils": "8.45.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg=="],
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
"undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
@@ -921,7 +955,7 @@
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"vite": ["vite@7.0.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg=="],
"vite": ["vite@7.1.9", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg=="],
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
@@ -945,23 +979,47 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"@babel/core/@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
"@babel/core/@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
"@babel/core/@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="],
"@babel/core/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="],
"@babel/helper-module-imports/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="],
"@babel/helpers/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" }, "bundled": true }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
"@tailwindcss/node/magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" }, "bundled": true }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -969,6 +1027,10 @@
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
"engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -977,8 +1039,14 @@
"loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"make-dir/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
@@ -993,22 +1061,54 @@
"test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"vite-node/vite": ["vite@7.0.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg=="],
"vitest/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"vitest/vite": ["vite@7.0.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
"@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
"@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
"@babel/helper-module-transforms/@babel/traverse/@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
"@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
"@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@tailwindcss/node/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"cmdk/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
"cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
"cmdk/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
"cmdk/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"test-exclude/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"vite-node/vite/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
"vite-node/vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"vitest/tinyglobby/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
"vitest/vite/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],

View File

@@ -16,55 +16,55 @@
"@dnd-kit/utilities": "^3.2.2",
"@number-flow/react": "^0.5.10",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.539.0",
"lucide-react": "^0.544.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-day-picker": "^9.8.1",
"react-dom": "^19.1.0",
"react-router": "^7.7.1",
"react": "^19.2.0",
"react-day-picker": "^9.11.0",
"react-dom": "^19.2.0",
"react-router": "^7.9.3",
"recharts": "2.15.4",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11"
"tailwindcss": "^4.1.14"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@eslint/js": "^9.37.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/node": "^24.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react-swc": "^3.10.2",
"@types/node": "^24.6.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react-swc": "^4.1.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"eslint": "^9.37.0",
"eslint-plugin-react-hooks": "^6.1.1",
"eslint-plugin-react-refresh": "^0.4.23",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"tw-animate-css": "^1.3.6",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.9",
"vitest": "^3.2.4"
}
}

View File

@@ -1,21 +1,38 @@
import { lazy, Suspense } from 'react'
import { Navigate, Route, Routes } from 'react-router'
import { LocaleProvider } from './components/LocaleProvider'
import { ThemeProvider } from './components/ThemeProvider'
import { Toaster } from './components/ui/sonner'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { SocketProvider } from './contexts/SocketContext'
import { AccountPage } from './pages/AccountPage'
import { AuthCallbackPage } from './pages/AuthCallbackPage'
import { DashboardPage } from './pages/DashboardPage'
import { ExtractionsPage } from './pages/ExtractionsPage'
import { LoginPage } from './pages/LoginPage'
import { PlaylistEditPage } from './pages/PlaylistEditPage'
import { PlaylistsPage } from './pages/PlaylistsPage'
import { RegisterPage } from './pages/RegisterPage'
import { SchedulersPage } from './pages/SchedulersPage'
import { SoundsPage } from './pages/SoundsPage'
import { SettingsPage } from './pages/admin/SettingsPage'
import { UsersPage } from './pages/admin/UsersPage'
// Lazy load all pages for code splitting
const AccountPage = lazy(() => import('./pages/AccountPage').then(m => ({ default: m.AccountPage })))
const AuthCallbackPage = lazy(() => import('./pages/AuthCallbackPage').then(m => ({ default: m.AuthCallbackPage })))
const DashboardPage = lazy(() => import('./pages/DashboardPage').then(m => ({ default: m.DashboardPage })))
const ExtractionsPage = lazy(() => import('./pages/ExtractionsPage').then(m => ({ default: m.ExtractionsPage })))
const LoginPage = lazy(() => import('./pages/LoginPage').then(m => ({ default: m.LoginPage })))
const PlaylistEditPage = lazy(() => import('./pages/PlaylistEditPage').then(m => ({ default: m.PlaylistEditPage })))
const PlaylistsPage = lazy(() => import('./pages/PlaylistsPage').then(m => ({ default: m.PlaylistsPage })))
const RegisterPage = lazy(() => import('./pages/RegisterPage').then(m => ({ default: m.RegisterPage })))
const SchedulersPage = lazy(() => import('./pages/SchedulersPage').then(m => ({ default: m.SchedulersPage })))
const SequencerPage = lazy(() => import('./pages/SequencerPage').then(m => ({ default: m.SequencerPage })))
const SoundsPage = lazy(() => import('./pages/SoundsPage').then(m => ({ default: m.SoundsPage })))
const TTSPage = lazy(() => import('./pages/TTSPage').then(m => ({ default: m.TTSPage })))
const SettingsPage = lazy(() => import('./pages/admin/SettingsPage').then(m => ({ default: m.SettingsPage })))
const UsersPage = lazy(() => import('./pages/admin/UsersPage').then(m => ({ default: m.UsersPage })))
// Loading component for lazy-loaded routes
function PageLoader() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
</div>
)
}
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
@@ -61,6 +78,7 @@ function AppRoutes() {
const { user } = useAuth()
return (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route
path="/login"
@@ -111,6 +129,22 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/tts"
element={
<ProtectedRoute>
<TTSPage />
</ProtectedRoute>
}
/>
<Route
path="/sequencer"
element={
<ProtectedRoute>
<SequencerPage />
</ProtectedRoute>
}
/>
<Route
path="/schedulers"
element={
@@ -144,6 +178,7 @@ function AppRoutes() {
}
/>
</Routes>
</Suspense>
)
}
@@ -154,7 +189,7 @@ function App() {
<AuthProvider>
<SocketProvider>
<AppRoutes />
<Toaster richColors position='top-right' />
<Toaster richColors position='top-center' />
</SocketProvider>
</AuthProvider>
</ThemeProvider>

View File

@@ -12,8 +12,9 @@ import {
SidebarProvider,
SidebarTrigger,
} from '@/components/ui/sidebar'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { AppSidebar } from './AppSidebar'
import { GlobalSearch } from './GlobalSearch'
import { Player, type PlayerDisplayMode } from './player/Player'
interface AppLayoutProps {
@@ -43,11 +44,29 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
},
)
const [isSearchOpen, setIsSearchOpen] = useState(false)
// Handle keyboard shortcut for global search
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault()
setIsSearchOpen(true)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
// Note: localStorage is managed by the Player component
return (
<SidebarProvider>
<AppSidebar showCompactPlayer={playerDisplayMode === 'sidebar'} />
<AppSidebar
showCompactPlayer={playerDisplayMode === 'sidebar'}
onSearchClick={() => setIsSearchOpen(true)}
/>
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2">
<div className="flex items-center gap-2 px-4">
@@ -80,6 +99,7 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">{children}</div>
</SidebarInset>
<Player onPlayerModeChange={setPlayerDisplayMode} />
<GlobalSearch isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
</SidebarProvider>
)
}

View File

@@ -1,3 +1,4 @@
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
Sidebar,
@@ -15,6 +16,9 @@ import {
PlayCircle,
Settings,
Users,
AudioLines,
Mic,
Search,
} from 'lucide-react'
import { CreditsNav } from './nav/CreditsNav'
import { NavGroup } from './nav/NavGroup'
@@ -25,9 +29,10 @@ import { CompactPlayer } from './player/CompactPlayer'
interface AppSidebarProps {
showCompactPlayer?: boolean
onSearchClick?: () => void
}
export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
export function AppSidebar({ showCompactPlayer = false, onSearchClick }: AppSidebarProps) {
const { user, logout } = useAuth()
if (!user) return null
@@ -44,12 +49,28 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
</SidebarHeader>
<SidebarContent>
<div className="px-2 py-1">
<Button
variant="outline"
className="w-full justify-start gap-2 group-data-[collapsible=icon]:justify-center"
onClick={onSearchClick}
>
<Search className="h-4 w-4" />
<span className="group-data-[collapsible=icon]:hidden">Search</span>
<kbd className="ml-auto pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100 group-data-[collapsible=icon]:hidden">
<span className="text-xs"></span>F
</kbd>
</Button>
</div>
<Separator className="my-2" />
<NavGroup label="Application">
<NavItem href="/" icon={Home} title="Dashboard" />
<NavItem href="/sounds" icon={Music} title="Sounds" />
<NavItem href="/playlists" icon={PlayCircle} title="Playlists" />
<NavItem href="/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' && (

View 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>
)
}

View File

@@ -2,7 +2,7 @@ import { StatisticCard } from '@/components/dashboard/StatisticCard'
import { NumberFlowDuration } from '@/components/ui/number-flow-duration'
import { NumberFlowSize } from '@/components/ui/number-flow-size'
import NumberFlow from '@number-flow/react'
import { Clock, HardDrive, Music, Play, Volume2 } from 'lucide-react'
import { Clock, HardDrive, Music, Play, Volume2, MessageSquare } from 'lucide-react'
interface SoundboardStatistics {
sound_count: number
@@ -18,12 +18,20 @@ interface TrackStatistics {
total_size: number
}
interface TTSStatistics {
sound_count: number
total_play_count: number
total_duration: number
total_size: number
}
interface StatisticsGridProps {
soundboardStatistics: SoundboardStatistics
trackStatistics: TrackStatistics
ttsStatistics: TTSStatistics
}
export function StatisticsGrid({ soundboardStatistics, trackStatistics }: StatisticsGridProps) {
export function StatisticsGrid({ soundboardStatistics, trackStatistics, ttsStatistics }: StatisticsGridProps) {
return (
<div className="space-y-6">
<div>
@@ -109,6 +117,48 @@ export function StatisticsGrid({ soundboardStatistics, trackStatistics }: Statis
/>
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-3 text-muted-foreground">
TTS Statistics
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatisticCard
title="Total TTS"
icon={MessageSquare}
value={<NumberFlow value={ttsStatistics.sound_count} />}
description="Text-to-speech audio files"
/>
<StatisticCard
title="Total Plays"
icon={Play}
value={<NumberFlow value={ttsStatistics.total_play_count} />}
description="All-time play count"
/>
<StatisticCard
title="Total Duration"
icon={Clock}
value={
<NumberFlowDuration
duration={ttsStatistics.total_duration}
variant="wordy"
/>
}
description="Combined TTS duration"
/>
<StatisticCard
title="Total Size"
icon={HardDrive}
value={
<NumberFlowSize
size={ttsStatistics.total_size}
binary={true}
/>
}
description="Original + normalized files"
/>
</div>
</div>
</div>
)
}

View 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>
)
}

View File

@@ -93,11 +93,28 @@ export function ExtractionsHeader({
</div>
<div className="flex gap-2">
<Select
value={statusFilter}
onValueChange={value => onStatusFilterChange(value as ExtractionStatus | 'all')}
>
<SelectTrigger className="w-[160px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="processing">Processing</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
</SelectContent>
</Select>
<Select
value={sortBy}
onValueChange={value => onSortByChange(value as ExtractionSortField)}
>
<SelectTrigger className="w-[180px]">
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
@@ -122,23 +139,6 @@ export function ExtractionsHeader({
)}
</Button>
<Select
value={statusFilter}
onValueChange={value => onStatusFilterChange(value as ExtractionStatus | 'all')}
>
<SelectTrigger className="w-[120px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="processing">Processing</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"

View File

@@ -99,6 +99,7 @@ export function ExtractionsRow({ extraction, onExtractionDeleted }: ExtractionsR
tiktok: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
twitter: 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300',
instagram: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
dailymotion: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300',
}
const colorClass =
@@ -117,10 +118,10 @@ export function ExtractionsRow({ extraction, onExtractionDeleted }: ExtractionsR
<TableRow className="hover:bg-muted/50">
<TableCell>
<div className="min-w-0">
<div className="font-medium truncate">
{extraction.title || 'Extracting...'}
<div className="font-medium truncate max-w-80">
{extraction.title || 'Processing...'}
</div>
<div className="text-sm text-muted-foreground truncate max-w-64">
<div className="text-sm text-muted-foreground">
{extraction.url}
</div>
</div>

View File

@@ -31,7 +31,7 @@ export function StopSoundsButton() {
onClick={handleStopSounds}
disabled={isLoading}
tooltip={tooltipText}
className="group-data-[collapsible=icon]:justify-center text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-950/50"
className="group-data-[collapsible=icon]:justify-center text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-950/50 cursor-pointer disabled:cursor-not-allowed"
>
<Square className="h-5 w-5 fill-current" />
<span className="font-semibold group-data-[collapsible=icon]:hidden">

View File

@@ -1,6 +1,3 @@
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { filesService } from '@/lib/api/services/files'
import {
type MessageResponse,
type PlayerState,
@@ -8,19 +5,11 @@ import {
} from '@/lib/api/services/player'
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
import { cn } from '@/lib/utils'
import {
Maximize2,
Music,
Pause,
Play,
SkipBack,
SkipForward,
Volume2,
VolumeX,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { NumberFlowDuration } from '../ui/number-flow-duration'
import { CompactPlayerControls } from './CompactPlayerControls'
import { CompactPlayerProgress } from './CompactPlayerProgress'
import { CompactPlayerTrackInfo } from './CompactPlayerTrackInfo'
interface CompactPlayerProps {
className?: string
@@ -33,6 +22,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
volume: 80,
previous_volume: 80,
position: 0,
play_next_queue: [],
})
const [isLoading, setIsLoading] = useState(false)
@@ -107,146 +97,50 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
}
}, [state.volume, executeAction])
// // Don't show if no current sound
// if (!state.current_sound) {
// return null
// }
return (
<div className={cn('w-full', className)}>
{/* Collapsed state - only play/pause button */}
<div className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:justify-center hidden">
<Button
size="sm"
variant="ghost"
onClick={handlePlayPause}
disabled={isLoading}
className="h-8 w-8 p-0"
title={state.status === 'playing' ? 'Pause' : 'Play'}
>
{state.status === 'playing' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</div>
{/* Expanded state - full player */}
<div className="group-data-[collapsible=icon]:hidden">
{/* Track Info */}
<div className="flex items-center gap-2 mb-3 px-1">
<div className="flex-shrink-0 w-8 h-8 bg-muted rounded flex items-center justify-center overflow-hidden">
{state.current_sound?.thumbnail ? (
<img
src={filesService.getThumbnailUrl(state.current_sound.id)}
alt={state.current_sound.name}
className="w-full h-full object-cover"
onError={e => {
// Hide image and show music icon if thumbnail fails to load
const target = e.target as HTMLImageElement
target.style.display = 'none'
const musicIcon = target.nextElementSibling as HTMLElement
if (musicIcon) musicIcon.style.display = 'block'
}}
/>
) : null}
<Music
className={cn(
'h-4 w-4 text-muted-foreground',
state.current_sound?.thumbnail ? 'hidden' : 'block',
)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{state.current_sound?.name || 'No track selected'}
</div>
<div className="text-xs text-muted-foreground">
{state.playlist?.name}
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
const handleExpand = useCallback(() => {
const expandFn = (
window as unknown as { __expandPlayerFromSidebar?: () => void }
).__expandPlayerFromSidebar
if (expandFn) expandFn()
}}
className="h-6 w-6 p-0 flex-shrink-0"
title="Expand Player"
>
<Maximize2 className="h-3 w-3" />
</Button>
</div>
}, [])
{/* Progress Bar */}
<div className="mb-3">
<Progress
value={(state.position / (state.duration || 1)) * 100}
className="w-full h-1"
return (
<div className={cn('w-full', className)}>
{/* Collapsed state - only play/pause button */}
<CompactPlayerControls
status={state.status}
volume={state.volume}
isLoading={isLoading}
onPlayPause={handlePlayPause}
onPrevious={handlePrevious}
onNext={handleNext}
onVolumeToggle={handleVolumeToggle}
variant="collapsed"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span><NumberFlowDuration duration={state.position} /></span>
<span><NumberFlowDuration duration={state.duration || 0} /></span>
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-between gap-1">
<Button
size="sm"
variant="ghost"
onClick={handlePrevious}
disabled={isLoading}
className="h-7 w-7 p-0"
title="Previous"
>
<SkipBack className="h-3 w-3" />
</Button>
{/* Expanded state - full player */}
<div className="group-data-[collapsible=icon]:hidden">
<CompactPlayerTrackInfo
currentSound={state.current_sound}
playlistName={state.playlist?.name}
onExpand={handleExpand}
/>
<Button
size="sm"
variant="ghost"
onClick={handlePlayPause}
disabled={isLoading}
className="h-8 w-8 p-0"
title={state.status === 'playing' ? 'Pause' : 'Play'}
>
{state.status === 'playing' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<CompactPlayerProgress
position={state.position}
duration={state.duration || 0}
/>
<Button
size="sm"
variant="ghost"
onClick={handleNext}
disabled={isLoading}
className="h-7 w-7 p-0"
title="Next"
>
<SkipForward className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleVolumeToggle}
className="h-7 w-7 p-0"
title={state.volume === 0 ? 'Unmute' : 'Mute'}
>
{state.volume === 0 ? (
<VolumeX className="h-3 w-3" />
) : (
<Volume2 className="h-3 w-3" />
)}
</Button>
</div>
<CompactPlayerControls
status={state.status}
volume={state.volume}
isLoading={isLoading}
onPlayPause={handlePlayPause}
onPrevious={handlePrevious}
onNext={handleNext}
onVolumeToggle={handleVolumeToggle}
variant="expanded"
/>
</div>
</div>
)

View 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>
)
})

View 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>
)
})

View 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>
)
})

View 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>
)
}

View File

@@ -1,14 +1,5 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Progress } from '@/components/ui/progress'
import { Slider } from '@/components/ui/slider'
import { filesService } from '@/lib/api/services/files'
import {
type MessageResponse,
@@ -16,36 +7,74 @@ import {
type PlayerState,
playerService,
} from '@/lib/api/services/player'
import { soundsService } from '@/lib/api/services/sounds'
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
import {
ArrowRight,
ArrowRightToLine,
Download,
ExternalLink,
List,
Maximize2,
Minimize2,
MoreVertical,
Music,
Pause,
Play,
Repeat,
Repeat1,
Shuffle,
SkipBack,
SkipForward,
Square,
Volume2,
VolumeX,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Playlist } from './Playlist'
import { PlayNextQueue } from './PlayNextQueue'
import { PlayerControls } from './PlayerControls'
import { PlayerProgress } from './PlayerProgress'
import { PlayerTrackInfo } from './PlayerTrackInfo'
export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized' | 'sidebar'
// Helper function to deep compare player states to prevent unnecessary re-renders
function isPlayerStateEqual(state1: PlayerState, state2: PlayerState): boolean {
// Quick reference equality check first
if (state1 === state2) return true
// Compare primitive properties
if (
state1.status !== state2.status ||
state1.mode !== state2.mode ||
state1.volume !== state2.volume ||
state1.previous_volume !== state2.previous_volume ||
state1.position !== state2.position ||
state1.duration !== state2.duration ||
state1.index !== state2.index
) {
return false
}
// Compare current_sound object
if (state1.current_sound !== state2.current_sound) {
if (!state1.current_sound || !state2.current_sound) return false
if (
state1.current_sound.id !== state2.current_sound.id ||
state1.current_sound.name !== state2.current_sound.name ||
state1.current_sound.thumbnail !== state2.current_sound.thumbnail ||
state1.current_sound.extract_url !== state2.current_sound.extract_url
) {
return false
}
}
// Compare playlist object (only shallow comparison for performance)
if (state1.playlist !== state2.playlist) {
if (!state1.playlist || !state2.playlist) return false
if (
state1.playlist.id !== state2.playlist.id ||
state1.playlist.sounds.length !== state2.playlist.sounds.length
) {
return false
}
}
// Compare play_next_queue length
if (state1.play_next_queue.length !== state2.play_next_queue.length) {
return false
}
return true
}
interface PlayerProps {
className?: string
onPlayerModeChange?: (mode: PlayerDisplayMode) => void
@@ -58,6 +87,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
volume: 80,
previous_volume: 80,
position: 0,
play_next_queue: [],
})
const [displayMode, setDisplayMode] = useState<PlayerDisplayMode>(() => {
// Initialize from localStorage or default to 'normal'
@@ -84,6 +114,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
const [showPlaylist, setShowPlaylist] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Load initial state
useEffect(() => {
const loadState = async () => {
@@ -97,12 +128,19 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
loadState()
}, [])
// Listen for player state updates
// Listen for player state updates with optimization
const stateRef = useRef(state)
stateRef.current = state
useEffect(() => {
const handlePlayerState = (...args: unknown[]) => {
const newState = args[0] as PlayerState
// Only update state if it actually changed to prevent unnecessary re-renders
if (!isPlayerStateEqual(stateRef.current, newState)) {
setState(newState)
}
}
playerEvents.on(PLAYER_EVENTS.PLAYER_STATE, handlePlayerState)
@@ -216,20 +254,16 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
}
}, [state.current_sound])
const getModeIcon = () => {
switch (state.mode) {
case 'continuous':
return <ArrowRight className="h-4 w-4" />
case 'loop':
return <Repeat className="h-4 w-4" />
case 'loop_one':
return <Repeat1 className="h-4 w-4" />
case 'random':
return <Shuffle className="h-4 w-4" />
default:
return <ArrowRightToLine className="h-4 w-4" />
}
const handleStopAllSounds = useCallback(async () => {
try {
await soundsService.stopSounds()
toast.success('All sounds stopped')
} catch (error) {
console.error('Failed to stop all sounds:', error)
toast.error('Failed to stop all sounds')
}
}, [])
const expandFromSidebar = useCallback(() => {
setDisplayMode('normal')
@@ -252,46 +286,17 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
<Card className="w-48 bg-background/90 backdrop-blur-sm pt-0 pb-0">
<CardContent className="p-2">
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={handlePrevious}
disabled={isLoading}
className="h-8 w-8 p-0"
>
<SkipBack className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={handlePlayPause}
disabled={isLoading}
className="h-8 w-8 p-0"
>
{state.status === 'playing' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleStop}
disabled={isLoading}
className="h-8 w-8 p-0"
>
<Square className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleNext}
disabled={isLoading}
className="h-8 w-8 p-0"
>
<SkipForward className="h-4 w-4" />
</Button>
<PlayerControls
status={state.status}
mode={state.mode}
isLoading={isLoading}
onPlayPause={handlePlayPause}
onStop={handleStop}
onPrevious={handlePrevious}
onNext={handleNext}
onModeChange={handleModeChange}
variant="minimized"
/>
<Button
size="sm"
variant="ghost"
@@ -330,180 +335,32 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
</Button>
</div>
{/* Album Art / Thumbnail */}
<div className="mb-4">
{state.current_sound?.thumbnail ? (
<div className="w-full aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden">
<img
src={filesService.getThumbnailUrl(state.current_sound.id)}
alt={state.current_sound.name}
className="w-full h-full object-cover"
onError={e => {
// Hide image and show music icon if thumbnail fails to load
const target = e.target as HTMLImageElement
target.style.display = 'none'
const musicIcon = target.nextElementSibling as HTMLElement
if (musicIcon) musicIcon.style.display = 'block'
}}
<PlayerTrackInfo
currentSound={state.current_sound}
onDownloadSound={handleDownloadSound}
/>
<Music
className={cn(
'h-8 w-8 text-muted-foreground',
state.current_sound?.thumbnail ? 'hidden' : 'block',
)}
<PlayerProgress
position={state.position}
duration={state.duration || 0}
onSeek={handleSeek}
/>
</div>
) : null}
</div>
{/* Track Info */}
<div className="mb-4 text-center">
<div className="flex items-center justify-center gap-2">
<h3 className="font-medium text-sm truncate">
{state.current_sound?.name || 'No track selected'}
</h3>
{state.current_sound &&
(state.current_sound.extract_url || state.current_sound.id) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{state.current_sound.extract_url && (
<DropdownMenuItem asChild>
<a
href={state.current_sound.extract_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
Source
</a>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={handleDownloadSound}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
File
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Progress Bar */}
<div className="mb-4">
<Progress
value={(state.position / (state.duration || 1)) * 100}
className="w-full h-2 cursor-pointer"
onClick={e => {
const rect = e.currentTarget.getBoundingClientRect()
const clickX = e.clientX - rect.left
const percentage = clickX / rect.width
const newPosition = Math.round(percentage * (state.duration || 0))
handleSeek([newPosition])
}}
<PlayerControls
status={state.status}
mode={state.mode}
isLoading={isLoading}
showPlaylistButton={true}
volume={state.volume}
onPlayPause={handlePlayPause}
onStop={handleStop}
onPrevious={handlePrevious}
onNext={handleNext}
onModeChange={handleModeChange}
onTogglePlaylist={() => setShowPlaylist(!showPlaylist)}
onVolumeChange={handleVolumeChange}
onMute={handleMute}
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{formatDuration(state.position)}</span>
<span>{formatDuration(state.duration || 0)}</span>
</div>
</div>
{/* Main Controls */}
<div className="flex items-center justify-center gap-2 mb-4">
<Button
size="sm"
variant="ghost"
onClick={handleModeChange}
className="h-8 w-8 p-0"
title={`Mode: ${state.mode.replace('_', ' ')}`}
>
{getModeIcon()}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handlePrevious}
disabled={isLoading}
>
<SkipBack className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handlePlayPause}
disabled={isLoading}
className="h-10 w-10 rounded-full"
>
{state.status === 'playing' ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleStop}
disabled={isLoading}
>
<Square className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleNext}
disabled={isLoading}
>
<SkipForward className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setShowPlaylist(!showPlaylist)}
className="h-8 w-8 p-0"
title="Toggle Playlist"
>
<List className="h-4 w-4" />
</Button>
</div>
{/* Secondary Controls */}
<div className="flex items-center justify-between">
<Badge variant="secondary" className="text-xs">
{state.mode.replace('_', ' ')}
</Badge>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={handleMute}
className="h-8 w-8 p-0"
>
{state.volume === 0 ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
<div className="w-16">
<Slider
value={[state.volume]}
max={100}
step={1}
onValueChange={handleVolumeChange}
className="w-full"
/>
</div>
</div>
</div>
{/* Playlist */}
{showPlaylist && state.playlist && (
@@ -518,6 +375,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
)
}
/>
{/* Play Next Queue */}
{state.play_next_queue.length > 0 && (
<div className="mt-2 border-t">
<PlayNextQueue queue={state.play_next_queue} />
</div>
)}
</div>
)}
</CardContent>
@@ -529,6 +393,16 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
{/* Header */}
<div className="p-4 border-b flex items-center justify-between">
<h2 className="text-lg font-semibold">Now Playing</h2>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={handleStopAllSounds}
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-950/50"
>
<Square className="h-4 w-4 mr-2 fill-current" />
Stop All Sounds
</Button>
<Button
size="sm"
variant="ghost"
@@ -538,179 +412,50 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
Exit Fullscreen
</Button>
</div>
</div>
<div className="flex-1 flex">
{/* Main Player Area */}
<div className="flex-1 flex flex-col items-center justify-center p-8">
{/* Large Album Art */}
<div className="max-w-300 max-h-200 aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden mb-8">
{state.current_sound?.thumbnail ? (
<img
src={filesService.getThumbnailUrl(state.current_sound.id)}
alt={state.current_sound.name}
className="w-full h-full object-cover"
onError={e => {
// Hide image and show music icon if thumbnail fails to load
const target = e.target as HTMLImageElement
target.style.display = 'none'
const musicIcon = target.nextElementSibling as HTMLElement
if (musicIcon) musicIcon.style.display = 'block'
}}
<PlayerTrackInfo
currentSound={state.current_sound}
onDownloadSound={handleDownloadSound}
variant="maximized"
/>
) : null}
<Music
className={cn(
'h-32 w-32 text-muted-foreground',
state.current_sound?.thumbnail ? 'hidden' : 'block',
)}
<PlayerProgress
position={state.position}
duration={state.duration || 0}
onSeek={handleSeek}
variant="maximized"
/>
</div>
{/* Track Info */}
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-3 mb-2">
<h1 className="text-2xl font-bold">
{state.current_sound?.name || 'No track selected'}
</h1>
{state.current_sound &&
(state.current_sound.extract_url || state.current_sound.id) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{state.current_sound.extract_url && (
<DropdownMenuItem asChild>
<a
href={state.current_sound.extract_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
Source
</a>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={handleDownloadSound}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
File
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Progress Bar */}
<div className="w-full max-w-md mb-8">
<Progress
value={(state.position / (state.duration || 1)) * 100}
className="w-full h-3 cursor-pointer"
onClick={e => {
const rect = e.currentTarget.getBoundingClientRect()
const clickX = e.clientX - rect.left
const percentage = clickX / rect.width
const newPosition = Math.round(
percentage * (state.duration || 0),
)
handleSeek([newPosition])
}}
<PlayerControls
status={state.status}
mode={state.mode}
isLoading={isLoading}
volume={state.volume}
onPlayPause={handlePlayPause}
onStop={handleStop}
onPrevious={handlePrevious}
onNext={handleNext}
onModeChange={handleModeChange}
onVolumeChange={handleVolumeChange}
onMute={handleMute}
variant="maximized"
/>
<div className="flex justify-between text-sm text-muted-foreground mt-2">
<span>{formatDuration(state.position)}</span>
<span>{formatDuration(state.duration || 0)}</span>
</div>
</div>
{/* Large Controls */}
<div className="flex items-center gap-4 mb-8">
<Button
size="lg"
variant="ghost"
onClick={handlePrevious}
disabled={isLoading}
>
<SkipBack className="h-6 w-6" />
</Button>
<Button
size="lg"
onClick={handlePlayPause}
disabled={isLoading}
className="h-16 w-16 rounded-full"
>
{state.status === 'playing' ? (
<Pause className="h-8 w-8" />
) : (
<Play className="h-8 w-8" />
)}
</Button>
<Button
size="lg"
variant="ghost"
onClick={handleStop}
disabled={isLoading}
>
<Square className="h-6 w-6" />
</Button>
<Button
size="lg"
variant="ghost"
onClick={handleNext}
disabled={isLoading}
>
<SkipForward className="h-6 w-6" />
</Button>
</div>
{/* Secondary Controls */}
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={handleModeChange}>
{getModeIcon()}
</Button>
<Badge variant="secondary">{state.mode.replace('_', ' ')}</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={handleMute}>
{state.volume === 0 ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
<div className="w-24">
<Slider
value={[state.volume]}
max={100}
step={1}
onValueChange={handleVolumeChange}
className="w-full"
/>
</div>
<span className="text-sm text-muted-foreground w-8">
{Math.round(state.volume)}%
</span>
</div>
</div>
</div>
{/* Playlist Sidebar */}
{state.playlist && (
<div className="w-96 border-l bg-muted/10 backdrop-blur-sm">
<div className="p-4 border-b">
<div className="w-96 border-l bg-muted/10 backdrop-blur-sm flex flex-col">
<div className="p-4 border-b flex-shrink-0">
<h3 className="font-semibold">Playlist</h3>
<p className="text-sm text-muted-foreground">
{state.playlist.sounds.length} tracks
</p>
</div>
<div className="p-4">
<div className="p-4 overflow-y-auto flex-1">
<Playlist
playlist={state.playlist}
currentIndex={state.index}
@@ -722,6 +467,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
}
variant="maximized"
/>
{/* Play Next Queue */}
{state.play_next_queue.length > 0 && (
<div className="mt-2 border-t">
<PlayNextQueue queue={state.play_next_queue} />
</div>
)}
</div>
</div>
)}

View 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>
</>
)
})

View 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>
)
})

View 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>
</>
)
})

View File

@@ -1,10 +1,19 @@
import { useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { filesService } from '@/lib/api/services/files'
import { type PlayerPlaylist } from '@/lib/api/services/player'
import { type PlayerPlaylist, playerService } from '@/lib/api/services/player'
import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
import { Music, Play } from 'lucide-react'
import { Music, Play, Search, X, ListPlus } from 'lucide-react'
interface PlaylistProps {
playlist: PlayerPlaylist
@@ -19,6 +28,20 @@ export function Playlist({
onTrackSelect,
variant = 'normal',
}: PlaylistProps) {
const [searchQuery, setSearchQuery] = useState('')
const filteredSounds = playlist.sounds.filter((sound) =>
sound.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const handleAddToPlayNext = async (soundId: number) => {
try {
await playerService.addToPlayNext(soundId)
} catch (error) {
console.error('Failed to add track to play next:', error)
}
}
return (
<div className="w-full">
{/* Header */}
@@ -29,26 +52,51 @@ export function Playlist({
</Badge>
</div>
{/* Search */}
<div className="relative mb-2">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Search tracks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 pr-8 text-xs"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-0 top-1/2 -translate-y-1/2 h-8 w-8"
onClick={() => setSearchQuery('')}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
{/* Track List */}
<ScrollArea
className={variant === 'maximized' ? 'h-[calc(100vh-230px)]' : 'h-60'}
className={variant === 'maximized' ? 'h-[calc(100vh-320px)]' : 'h-60'}
>
<div className="w-full">
{playlist.sounds.map((sound, index) => (
{filteredSounds.map((sound) => {
const originalIndex = playlist.sounds.findIndex((s) => s.id === sound.id)
return (
<ContextMenu key={sound.id}>
<ContextMenuTrigger asChild>
<div
key={sound.id}
className={cn(
'grid grid-cols-10 gap-2 items-center py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer text-xs',
currentIndex === index && 'bg-primary/10 text-primary',
currentIndex === originalIndex && 'bg-primary/10 text-primary',
)}
onClick={() => onTrackSelect(index)}
onClick={() => onTrackSelect(originalIndex)}
>
{/* Track number/play icon - 1 column */}
<div className="col-span-1 flex justify-center">
{currentIndex === index ? (
{currentIndex === originalIndex ? (
<Play className="h-3 w-3" />
) : (
<span className="text-muted-foreground">{index + 1}</span>
<span className="text-muted-foreground">{originalIndex + 1}</span>
)}
</div>
@@ -78,7 +126,7 @@ export function Playlist({
className={cn(
'font-medium truncate block',
variant === 'maximized' ? 'text-sm' : 'text-xs',
currentIndex === index ? 'text-primary' : 'text-foreground',
currentIndex === originalIndex ? 'text-primary' : 'text-foreground',
)}
>
{sound.name}
@@ -92,7 +140,15 @@ export function Playlist({
</span>
</div>
</div>
))}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => handleAddToPlayNext(sound.id)}>
<ListPlus className="mr-2 h-4 w-4" />
Add to play next
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)})}
</div>
</ScrollArea>

View File

@@ -5,16 +5,17 @@ import type { Playlist } from '@/lib/api/services/playlists'
import { formatDateDistanceToNow } from '@/utils/format-date'
import { formatDuration } from '@/utils/format-duration'
import { cn } from '@/lib/utils'
import { Calendar, Clock, Edit, Heart, Music, Play, User } from 'lucide-react'
import { Calendar, Clock, Edit, Heart, Music, Play, Trash2, User } from 'lucide-react'
interface PlaylistRowProps {
playlist: Playlist
onEdit: (playlist: Playlist) => void
onSetCurrent: (playlist: Playlist) => void
onFavoriteToggle?: (playlistId: number, isFavorited: boolean) => void
onDelete?: (playlist: Playlist) => void
}
export function PlaylistRow({ playlist, onEdit, onSetCurrent, onFavoriteToggle }: PlaylistRowProps) {
export function PlaylistRow({ playlist, onEdit, onSetCurrent, onFavoriteToggle, onDelete }: PlaylistRowProps) {
const handleFavoriteToggle = () => {
if (onFavoriteToggle) {
onFavoriteToggle(playlist.id, !playlist.is_favorited)
@@ -121,6 +122,17 @@ export function PlaylistRow({ playlist, onEdit, onSetCurrent, onFavoriteToggle }
<Play className="h-4 w-4" />
</Button>
)}
{onDelete && !playlist.is_main && playlist.is_deletable && (
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(playlist)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
title={`Delete "${playlist.name}"`}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>

View File

@@ -13,9 +13,10 @@ interface PlaylistTableProps {
onEdit: (playlist: Playlist) => void
onSetCurrent: (playlist: Playlist) => void
onFavoriteToggle?: (playlistId: number, isFavorited: boolean) => void
onDelete?: (playlist: Playlist) => void
}
export function PlaylistTable({ playlists, onEdit, onSetCurrent, onFavoriteToggle }: PlaylistTableProps) {
export function PlaylistTable({ playlists, onEdit, onSetCurrent, onFavoriteToggle, onDelete }: PlaylistTableProps) {
return (
<div className="rounded-md border">
<Table>
@@ -39,6 +40,7 @@ export function PlaylistTable({ playlists, onEdit, onSetCurrent, onFavoriteToggl
onEdit={onEdit}
onSetCurrent={onSetCurrent}
onFavoriteToggle={onFavoriteToggle}
onDelete={onDelete}
/>
))}
</TableBody>

View File

@@ -38,8 +38,8 @@ interface CreateTaskDialogProps {
onCancel: () => void
}
const TASK_TYPES: TaskType[] = ['credit_recharge', 'play_sound', 'play_playlist']
const RECURRENCE_TYPES: RecurrenceType[] = ['none', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'cron']
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,

View File

@@ -14,7 +14,8 @@ import {
getTaskTypeLabel,
} from '@/lib/api/services/schedulers'
import {
CalendarPlus,
Filter,
Plus,
RefreshCw,
Search,
} from 'lucide-react'
@@ -34,7 +35,7 @@ interface SchedulersHeaderProps {
}
const TASK_STATUSES: TaskStatus[] = ['pending', 'running', 'completed', 'failed', 'cancelled']
const TASK_TYPES: TaskType[] = ['credit_recharge', 'play_sound', 'play_playlist']
const TASK_TYPES: TaskType[] = [/*'credit_recharge',*/ 'play_sound', 'play_playlist']
export function SchedulersHeader({
searchQuery,
@@ -64,18 +65,9 @@ export function SchedulersHeader({
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={loading}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={onCreateClick} size="sm">
<CalendarPlus className="h-4 w-4" />
Create Task
<Button onClick={onCreateClick}>
<Plus className="h-4 w-4 mr-2" />
Add Extraction
</Button>
</div>
</div>
@@ -96,7 +88,8 @@ export function SchedulersHeader({
value={statusFilter}
onValueChange={onStatusFilterChange}
>
<SelectTrigger className="w-[140px]">
<SelectTrigger className="w-[160px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
@@ -113,7 +106,8 @@ export function SchedulersHeader({
value={taskTypeFilter}
onValueChange={onTaskTypeFilterChange}
>
<SelectTrigger className="w-[140px]">
<SelectTrigger className="w-[200px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
@@ -125,6 +119,18 @@ export function SchedulersHeader({
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={onRefresh}
disabled={loading}
title="Refresh extractions"
>
<RefreshCw
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
/>
</Button>
</div>
</div>

View File

@@ -5,7 +5,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { formatDate } from '@/utils/format-date'
@@ -20,8 +19,6 @@ import {
import {
CalendarClock,
MoreHorizontal,
Pause,
Play,
Square,
} from 'lucide-react'
import { useState } from 'react'
@@ -29,44 +26,34 @@ import { toast } from 'sonner'
interface SchedulersTableProps {
tasks: ScheduledTask[]
onTaskUpdated?: (task: ScheduledTask) => void
onTaskDeleted?: (taskId: number) => void
}
export function SchedulersTable({ tasks, onTaskUpdated, onTaskDeleted }: SchedulersTableProps) {
export function SchedulersTable({ tasks, onTaskDeleted }: SchedulersTableProps) {
const [loadingActions, setLoadingActions] = useState<Record<number, boolean>>({})
const handleToggleActive = async (task: ScheduledTask) => {
if (loadingActions[task.id]) return
try {
setLoadingActions(prev => ({ ...prev, [task.id]: true }))
const updatedTask = await schedulersService.updateTask(task.id, {
is_active: !task.is_active,
})
onTaskUpdated?.(updatedTask)
toast.success(`Task ${task.is_active ? 'paused' : 'resumed'} successfully`)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update task'
toast.error(message)
} finally {
setLoadingActions(prev => ({ ...prev, [task.id]: false }))
}
}
const handleCancelTask = async (task: ScheduledTask) => {
if (loadingActions[task.id]) return
// Confirm deletion
const confirmMessage = `Are you sure you want to delete the task "${task.name}"?${
task.status === 'pending' || task.status === 'running'
? '\n\nThis task is currently active and will be stopped immediately.'
: ''
}\n\nThis action cannot be undone.`
if (!confirm(confirmMessage)) {
return
}
try {
setLoadingActions(prev => ({ ...prev, [task.id]: true }))
await schedulersService.cancelTask(task.id)
onTaskDeleted?.(task.id)
toast.success('Task cancelled successfully')
toast.success('Task deleted successfully')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to cancel task'
const message = error instanceof Error ? error.message : 'Failed to delete task'
toast.error(message)
} finally {
setLoadingActions(prev => ({ ...prev, [task.id]: false }))
@@ -85,7 +72,7 @@ export function SchedulersTable({ tasks, onTaskUpdated, onTaskDeleted }: Schedul
return (
<div className="space-y-4">
{tasks.map((task) => (
<Card key={task.id} className={task.is_active ? '' : 'opacity-60'}>
<Card key={task.id}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
@@ -94,11 +81,6 @@ export function SchedulersTable({ tasks, onTaskUpdated, onTaskDeleted }: Schedul
<Badge variant={getTaskStatusVariant(task.status)}>
{getTaskStatusLabel(task.status)}
</Badge>
{!task.is_active && (
<Badge variant="outline" className="text-muted-foreground">
Paused
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{getTaskTypeLabel(task.task_type)}
@@ -121,30 +103,12 @@ export function SchedulersTable({ tasks, onTaskUpdated, onTaskDeleted }: Schedul
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleToggleActive(task)}
disabled={task.status === 'completed' || task.status === 'cancelled'}
>
{task.is_active ? (
<>
<Pause className="h-4 w-4 mr-2" />
Pause Task
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
Resume Task
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleCancelTask(task)}
disabled={task.status === 'completed' || task.status === 'cancelled'}
className="text-destructive focus:text-destructive"
>
<Square className="h-4 w-4 mr-2" />
Cancel Task
Delete Task
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View 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'

View 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>
)
}

View 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>
)
}

View 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'

View File

@@ -1,4 +1,5 @@
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { type Sound } from '@/lib/api/services/sounds'
import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
@@ -27,6 +28,19 @@ export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }:
onFavoriteToggle(sound.id, !sound.is_favorited)
}
const getBadgeVariant = (type: Sound['type']) => {
switch (type) {
case 'SDB':
return 'default'
case 'TTS':
return 'secondary'
case 'EXT':
return 'outline'
default:
return 'default'
}
}
return (
<Card
onClick={handlePlaySound}
@@ -36,6 +50,14 @@ export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }:
)}
>
<CardContent className="grid grid-cols-1 pl-3 pr-3 gap-1">
{/* Type badge */}
<Badge
variant={getBadgeVariant(sound.type)}
className="absolute top-2 left-2 text-xs px-1 py-0 h-4 text-[10px]"
>
{sound.type}
</Badge>
{/* Favorite button */}
<button
data-favorite-button
@@ -57,7 +79,7 @@ export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }:
/>
</button>
<h3 className="font-medium text-s truncate pr-8">{sound.name}</h3>
<h3 className="font-medium text-s truncate pl-8 pr-6">{sound.name}</h3>
<div className="grid grid-cols-2 gap-1 text-xs text-muted-foreground">
<div className="flex">
<Clock className="h-3.5 w-3.5 mr-0.5" />

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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">
&quot;{tts.text}&quot;
</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>
)
}

View 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>
)
}

View File

@@ -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: {

View File

@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,

View File

@@ -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}

View File

@@ -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)

View File

@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"

View File

@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"

View File

@@ -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}

View File

@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"

View File

@@ -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}

View File

@@ -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

View File

@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"

View File

@@ -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 />

View File

@@ -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>

View File

@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -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>
)

View File

@@ -12,11 +12,13 @@ import {
EXTRACTION_EVENTS,
PLAYER_EVENTS,
SOUND_EVENTS,
TTS_EVENTS,
USER_EVENTS,
authEvents,
extractionEvents,
playerEvents,
soundEvents,
ttsEvents,
userEvents,
} from '../lib/events'
import { extractionsService } from '../lib/api/services/extractions'
@@ -158,6 +160,28 @@ export function SocketProvider({ children }: SocketProviderProps) {
}
})
// Listen for TTS status updates
newSocket.on('tts_completed', data => {
// Emit local event for other components to listen to
ttsEvents.emit(TTS_EVENTS.TTS_COMPLETED, data)
toast.success('TTS generation completed', {
duration: 3000,
})
})
newSocket.on('tts_failed', data => {
const { error } = data
// Emit local event for other components to listen to
ttsEvents.emit(TTS_EVENTS.TTS_FAILED, data)
toast.error('TTS generation failed', {
description: error,
duration: 5000,
})
})
return newSocket
}, [user, fetchAndShowOngoingExtractions])

View File

@@ -1,4 +1,4 @@
import * as React from 'react'
import * as React from "react"
const MOBILE_BREAKPOINT = 768
@@ -10,9 +10,9 @@ export function useIsMobile() {
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile

View 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,
}), [])
)
}

View File

@@ -38,6 +38,7 @@ export interface PlayerState {
index?: number
current_sound?: PlayerSound
playlist?: PlayerPlaylist
play_next_queue: PlayerSound[]
}
export interface PlayerSeekRequest {
@@ -147,6 +148,13 @@ export class PlayerService {
async getState(): Promise<PlayerState> {
return apiClient.get<PlayerState>('/api/v1/player/state')
}
/**
* Add a sound to the play next queue
*/
async addToPlayNext(soundId: number): Promise<MessageResponse> {
return apiClient.post<MessageResponse>(`/api/v1/player/play-next/${soundId}`)
}
}
export const playerService = new PlayerService()

View File

@@ -6,7 +6,7 @@ export type TaskType = 'credit_recharge' | 'play_sound' | 'play_playlist'
export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
export type RecurrenceType = 'none' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'cron'
export type RecurrenceType = 'none' | 'minutely' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'cron'
// Task interfaces
export interface ScheduledTask {
@@ -166,6 +166,8 @@ export function getRecurrenceTypeLabel(recurrenceType: RecurrenceType): string {
switch (recurrenceType) {
case 'none':
return 'None'
case 'minutely':
return 'Minutely'
case 'hourly':
return 'Hourly'
case 'daily':

136
src/lib/api/services/tts.ts Normal file
View File

@@ -0,0 +1,136 @@
import { apiClient } from '../client'
export interface TTSRequest {
text: string
provider?: string
options?: Record<string, any>
}
export interface TTSResponse {
id: number
text: string
provider: string
options: Record<string, any>
status: string
error: string | null
sound_id: number | null
user_id: number
created_at: string
}
export interface TTSGenerateResponse {
message: string
tts: TTSResponse
}
export interface TTSProvider {
name: string
file_extension: string
supported_languages: string[]
option_schema: Record<string, any>
}
export interface TTSProvidersResponse {
[key: string]: TTSProvider
}
export type TTSSortField = 'created_at' | 'text' | 'provider'
export type TTSSortOrder = 'asc' | 'desc'
export interface GetTTSHistoryParams {
search?: string
sort_by?: TTSSortField
sort_order?: TTSSortOrder
page?: number
limit?: number
}
export interface GetTTSHistoryResponse {
tts: TTSResponse[]
total: number
total_pages: number
current_page: number
}
export const ttsService = {
async generateTTS(request: TTSRequest): Promise<TTSGenerateResponse> {
return await apiClient.post('/api/v1/tts', request)
},
async getTTSHistory(params?: GetTTSHistoryParams): Promise<GetTTSHistoryResponse> {
const searchParams = new URLSearchParams()
// Backend currently only supports limit and offset, not page-based pagination
if (params?.limit) {
searchParams.append('limit', params.limit.toString())
}
if (params?.page && params?.limit) {
// Convert page to offset
const offset = (params.page - 1) * params.limit
searchParams.append('offset', offset.toString())
}
const url = searchParams.toString()
? `/api/v1/tts?${searchParams.toString()}`
: '/api/v1/tts'
const ttsArray: TTSResponse[] = await apiClient.get(url)
// Apply client-side filtering and sorting since backend doesn't support them yet
let filteredTTS = ttsArray
if (params?.search) {
const search = params.search.toLowerCase()
filteredTTS = filteredTTS.filter(tts =>
tts.text.toLowerCase().includes(search) ||
tts.provider.toLowerCase().includes(search)
)
}
if (params?.sort_by && params?.sort_order) {
filteredTTS.sort((a, b) => {
let aValue = a[params.sort_by as keyof TTSResponse]
let bValue = b[params.sort_by as keyof TTSResponse]
// Convert dates to timestamps for comparison
if (params.sort_by === 'created_at') {
aValue = new Date(aValue as string).getTime()
bValue = new Date(bValue as string).getTime()
}
// Handle null values
if (aValue === null && bValue === null) return 0
if (aValue === null) return 1
if (bValue === null) return -1
const comparison = aValue > bValue ? 1 : -1
return params.sort_order === 'asc' ? comparison : -comparison
})
}
// Calculate pagination info
const limit = params?.limit || 50
const currentPage = params?.page || 1
const total = filteredTTS.length
const totalPages = Math.ceil(total / limit)
return {
tts: filteredTTS,
total,
total_pages: totalPages,
current_page: currentPage,
}
},
async getProviders(): Promise<TTSProvidersResponse> {
return await apiClient.get('/api/v1/tts/providers')
},
async getProvider(name: string): Promise<TTSProvider> {
return await apiClient.get(`/api/v1/tts/providers/${name}`)
},
async deleteTTS(ttsId: number): Promise<{ message: string }> {
return await apiClient.delete(`/api/v1/tts/${ttsId}`)
},
}

View File

@@ -0,0 +1,115 @@
export interface LanguageOption {
code: string
name: string
region?: string
}
export const GTTS_LANGUAGES: LanguageOption[] = [
{ code: 'af', name: 'Afrikaans' },
{ code: 'ar', name: 'Arabic' },
{ code: 'bg', name: 'Bulgarian' },
{ code: 'bn', name: 'Bengali' },
{ code: 'bs', name: 'Bosnian' },
{ code: 'ca', name: 'Catalan' },
{ code: 'cs', name: 'Czech' },
{ code: 'cy', name: 'Welsh' },
{ code: 'da', name: 'Danish' },
{ code: 'de', name: 'German' },
{ code: 'el', name: 'Greek' },
{ code: 'en', name: 'English', region: 'United States' },
{ code: 'en-au', name: 'English', region: 'Australia' },
{ code: 'en-ca', name: 'English', region: 'Canada' },
{ code: 'en-gb', name: 'English', region: 'UK' },
{ code: 'en-ie', name: 'English', region: 'Ireland' },
{ code: 'en-in', name: 'English', region: 'India' },
{ code: 'en-ng', name: 'English', region: 'Nigeria' },
{ code: 'en-nz', name: 'English', region: 'New Zealand' },
{ code: 'en-ph', name: 'English', region: 'Philippines' },
{ code: 'en-za', name: 'English', region: 'South Africa' },
{ code: 'en-tz', name: 'English', region: 'Tanzania' },
{ code: 'en-uk', name: 'English', region: 'United Kingdom' },
{ code: 'en-us', name: 'English', region: 'United States' },
{ code: 'eo', name: 'Esperanto' },
{ code: 'es', name: 'Spanish', region: 'Spain' },
{ code: 'es-es', name: 'Spanish', region: 'Spain' },
{ code: 'es-mx', name: 'Spanish', region: 'Mexico' },
{ code: 'es-us', name: 'Spanish', region: 'United States' },
{ code: 'et', name: 'Estonian' },
{ code: 'eu', name: 'Basque' },
{ code: 'fa', name: 'Persian' },
{ code: 'fi', name: 'Finnish' },
{ code: 'fr', name: 'French', region: 'France' },
{ code: 'fr-ca', name: 'French', region: 'Canada' },
{ code: 'fr-fr', name: 'French', region: 'France' },
{ code: 'ga', name: 'Irish' },
{ code: 'gu', name: 'Gujarati' },
{ code: 'he', name: 'Hebrew' },
{ code: 'hi', name: 'Hindi' },
{ code: 'hr', name: 'Croatian' },
{ code: 'hu', name: 'Hungarian' },
{ code: 'hy', name: 'Armenian' },
{ code: 'id', name: 'Indonesian' },
{ code: 'is', name: 'Icelandic' },
{ code: 'it', name: 'Italian' },
{ code: 'ja', name: 'Japanese' },
{ code: 'jw', name: 'Javanese' },
{ code: 'ka', name: 'Georgian' },
{ code: 'kk', name: 'Kazakh' },
{ code: 'km', name: 'Khmer' },
{ code: 'kn', name: 'Kannada' },
{ code: 'ko', name: 'Korean' },
{ code: 'la', name: 'Latin' },
{ code: 'lv', name: 'Latvian' },
{ code: 'mk', name: 'Macedonian' },
{ code: 'ml', name: 'Malayalam' },
{ code: 'mr', name: 'Marathi' },
{ code: 'ms', name: 'Malay' },
{ code: 'mt', name: 'Maltese' },
{ code: 'my', name: 'Myanmar (Burmese)' },
{ code: 'ne', name: 'Nepali' },
{ code: 'nl', name: 'Dutch' },
{ code: 'no', name: 'Norwegian' },
{ code: 'pa', name: 'Punjabi' },
{ code: 'pl', name: 'Polish' },
{ code: 'pt', name: 'Portuguese', region: 'Brazil' },
{ code: 'pt-br', name: 'Portuguese', region: 'Brazil' },
{ code: 'pt-pt', name: 'Portuguese', region: 'Portugal' },
{ code: 'ro', name: 'Romanian' },
{ code: 'ru', name: 'Russian' },
{ code: 'si', name: 'Sinhala' },
{ code: 'sk', name: 'Slovak' },
{ code: 'sl', name: 'Slovenian' },
{ code: 'sq', name: 'Albanian' },
{ code: 'sr', name: 'Serbian' },
{ code: 'su', name: 'Sundanese' },
{ code: 'sv', name: 'Swedish' },
{ code: 'sw', name: 'Swahili' },
{ code: 'ta', name: 'Tamil' },
{ code: 'te', name: 'Telugu' },
{ code: 'th', name: 'Thai' },
{ code: 'tl', name: 'Filipino' },
{ code: 'tr', name: 'Turkish' },
{ code: 'uk', name: 'Ukrainian' },
{ code: 'ur', name: 'Urdu' },
{ code: 'vi', name: 'Vietnamese' },
{ code: 'yo', name: 'Yoruba' },
{ code: 'zh', name: 'Chinese (Mandarin)' },
{ code: 'zh-cn', name: 'Chinese', region: 'China' },
{ code: 'zh-tw', name: 'Chinese', region: 'Taiwan' },
{ code: 'zu', name: 'Zulu' }
]
export function getLanguageDisplayName(lang: LanguageOption): string {
if (lang.region) {
return `${lang.name} (${lang.region}) - ${lang.code}`
}
return `${lang.name} - ${lang.code}`
}
export function getSortedLanguages(): LanguageOption[] {
return [...GTTS_LANGUAGES].sort((a, b) => {
const aDisplay = getLanguageDisplayName(a)
const bDisplay = getLanguageDisplayName(b)
return aDisplay.localeCompare(bDisplay)
})
}

View File

@@ -37,6 +37,7 @@ export const playerEvents = new EventEmitter()
export const soundEvents = new EventEmitter()
export const userEvents = new EventEmitter()
export const extractionEvents = new EventEmitter()
export const ttsEvents = new EventEmitter()
// Auth event types
export const AUTH_EVENTS = {
@@ -69,3 +70,11 @@ export const EXTRACTION_EVENTS = {
EXTRACTION_COMPLETED: 'extraction_completed',
EXTRACTION_FAILED: 'extraction_failed',
} as const
// TTS event types
export const TTS_EVENTS = {
TTS_STATUS_UPDATED: 'tts_status_updated',
TTS_CREATED: 'tts_created',
TTS_COMPLETED: 'tts_completed',
TTS_FAILED: 'tts_failed',
} as const

View File

@@ -3,6 +3,7 @@ import { DashboardHeader } from '@/components/dashboard/DashboardHeader'
import { ErrorState, LoadingSkeleton } from '@/components/dashboard/DashboardLoadingStates'
import { StatisticsGrid } from '@/components/dashboard/StatisticsGrid'
import { TopSoundsSection } from '@/components/dashboard/TopSoundsSection'
import { TopUsersSection } from '@/components/dashboard/TopUsersSection'
import { useCallback, useEffect, useState } from 'react'
interface SoundboardStatistics {
@@ -19,6 +20,13 @@ interface TrackStatistics {
total_size: number
}
interface TTSStatistics {
sound_count: number
total_play_count: number
total_duration: number
total_size: number
}
interface TopSound {
id: number
name: string
@@ -28,11 +36,19 @@ interface TopSound {
created_at: string | null
}
interface TopUser {
id: number
name: string
count: number
}
export function DashboardPage() {
const [soundboardStatistics, setSoundboardStatistics] =
useState<SoundboardStatistics | null>(null)
const [trackStatistics, setTrackStatistics] =
useState<TrackStatistics | null>(null)
const [ttsStatistics, setTtsStatistics] =
useState<TTSStatistics | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -44,6 +60,13 @@ export function DashboardPage() {
const [limit, setLimit] = useState(5)
const [refreshing, setRefreshing] = useState(false)
// Top users state
const [topUsers, setTopUsers] = useState<TopUser[]>([])
const [topUsersLoading, setTopUsersLoading] = useState(false)
const [metricType, setMetricType] = useState('sounds_played')
const [userPeriod, setUserPeriod] = useState('all_time')
const [userLimit, setUserLimit] = useState(5)
const fetchStatistics = useCallback(async () => {
try {
setError(null) // Clear previous errors
@@ -74,6 +97,19 @@ export function DashboardPage() {
const trackData = await trackResponse.json()
setTrackStatistics(trackData)
// Fetch TTS statistics separately to avoid Promise.all failures
const ttsResponse = await fetch('/api/v1/dashboard/tts-statistics', {
credentials: 'include'
})
if (!ttsResponse.ok) {
const errorText = await ttsResponse.text()
throw new Error(`Failed to fetch TTS statistics: ${errorText}`)
}
const ttsData = await ttsResponse.json()
setTtsStatistics(ttsData)
} catch (err) {
console.error('Dashboard statistics error:', err)
setError(err instanceof Error ? err.message : 'An error occurred')
@@ -134,18 +170,70 @@ export function DashboardPage() {
[soundType, period, limit],
)
const fetchTopUsers = useCallback(
async (showLoading = false) => {
try {
if (showLoading) {
setTopUsersLoading(true)
}
const response = await fetch(
`/api/v1/dashboard/top-users?metric_type=${metricType}&period=${userPeriod}&limit=${userLimit}`,
{ credentials: 'include' },
)
if (!response.ok) {
throw new Error('Failed to fetch top users')
}
const data = await response.json()
// Graceful update: merge new data while preserving animations
setTopUsers(prevTopUsers => {
// Create a map of existing users for efficient lookup
const existingUsersMap = new Map(
prevTopUsers.map(user => [user.id, user]),
)
// Update existing users and add new ones
return data.map((newUser: TopUser) => {
const existingUser = existingUsersMap.get(newUser.id)
if (existingUser) {
// Preserve object reference if data hasn't changed to avoid re-renders
if (
existingUser.name === newUser.name &&
existingUser.count === newUser.count
) {
return existingUser
}
}
return newUser
})
})
} catch (err) {
console.error('Failed to fetch top users:', err)
} finally {
if (showLoading) {
setTopUsersLoading(false)
}
}
},
[metricType, userPeriod, userLimit],
)
const refreshAll = useCallback(async () => {
setRefreshing(true)
try {
// Fetch statistics and top sounds sequentially to avoid Promise.all issues
await fetchStatistics()
await fetchTopSounds()
await fetchTopUsers()
} catch (err) {
console.error('Error during refresh:', err)
} finally {
setRefreshing(false)
}
}, [fetchStatistics, fetchTopSounds])
}, [fetchStatistics, fetchTopSounds, fetchTopUsers])
const retryFromError = useCallback(async () => {
setLoading(true)
@@ -174,23 +262,27 @@ export function DashboardPage() {
useEffect(() => {
const interval = setInterval(() => {
// Only auto-refresh if not currently loading or in error state
if (!loading && !refreshing && (!error || (soundboardStatistics && trackStatistics))) {
if (!loading && !refreshing && (!error || (soundboardStatistics && trackStatistics && ttsStatistics))) {
refreshAll()
}
}, 30000) // Increased to 30 seconds
return () => clearInterval(interval)
}, [refreshAll, loading, refreshing, error, soundboardStatistics, trackStatistics])
}, [refreshAll, loading, refreshing, error, soundboardStatistics, trackStatistics, ttsStatistics])
useEffect(() => {
fetchTopSounds(true) // Show loading on initial load and filter changes
}, [fetchTopSounds])
useEffect(() => {
fetchTopUsers(true) // Show loading on initial load and filter changes
}, [fetchTopUsers])
if (loading) {
return <LoadingSkeleton />
}
if (error && (!soundboardStatistics || !trackStatistics)) {
if (error && (!soundboardStatistics || !trackStatistics || !ttsStatistics)) {
return <ErrorState error={error} onRetry={retryFromError} />
}
@@ -204,10 +296,11 @@ export function DashboardPage() {
<DashboardHeader onRefresh={refreshAll} isRefreshing={refreshing} />
<div className="space-y-6">
{soundboardStatistics && trackStatistics && (
{soundboardStatistics && trackStatistics && ttsStatistics && (
<StatisticsGrid
soundboardStatistics={soundboardStatistics}
trackStatistics={trackStatistics}
ttsStatistics={ttsStatistics}
/>
)}
@@ -221,6 +314,17 @@ export function DashboardPage() {
onPeriodChange={setPeriod}
onLimitChange={setLimit}
/>
<TopUsersSection
topUsers={topUsers}
loading={topUsersLoading}
metricType={metricType}
period={userPeriod}
limit={userLimit}
onMetricTypeChange={setMetricType}
onPeriodChange={setUserPeriod}
onLimitChange={setUserLimit}
/>
</div>
</div>
</AppLayout>

View File

@@ -16,6 +16,7 @@ import { SimpleSortableRow } from '@/components/playlists/playlist-edit/SimpleSo
import { SortableTableRow } from '@/components/playlists/playlist-edit/SortableTableRow'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
@@ -46,7 +47,7 @@ import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { Minus, Music, Plus, RefreshCw } from 'lucide-react'
import { Minus, Music, Plus, RefreshCw, Search, X } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router'
import { toast } from 'sonner'
@@ -74,6 +75,7 @@ export function PlaylistEditPage() {
Sound | PlaylistSound | null
>(null)
const [dropPosition, setDropPosition] = useState<number | null>(null)
const [searchQuery, setSearchQuery] = useState('')
// dnd-kit sensors
const sensors = useSensors(
@@ -152,10 +154,18 @@ export function PlaylistEditPage() {
if (!isAddMode) {
// Entering add mode - fetch available sounds
await fetchAvailableSounds()
} else {
// Exiting add mode - clear search
setSearchQuery('')
}
setIsAddMode(!isAddMode)
}
// Filter available sounds based on search query
const filteredAvailableSounds = availableSounds.filter(sound =>
sound.name.toLowerCase().includes(searchQuery.toLowerCase())
)
useEffect(() => {
if (!isNaN(playlistId)) {
fetchPlaylist()
@@ -807,9 +817,30 @@ export function PlaylistEditPage() {
{/* Available Sounds */}
<div className="space-y-2">
<h4 className="font-medium text-sm text-muted-foreground mb-3">
Available EXT Sounds ({availableSounds.length})
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-sm text-muted-foreground">
Available EXT Sounds ({filteredAvailableSounds.length}/{availableSounds.length})
</h4>
</div>
<div className="relative mb-3">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search sounds..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 pr-8"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
title="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{availableSoundsLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
@@ -824,15 +855,23 @@ export function PlaylistEditPage() {
All EXT sounds are already in this playlist
</p>
</div>
) : filteredAvailableSounds.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Search className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No sounds match your search</p>
<p className="text-xs mt-1">
Try a different search term
</p>
</div>
) : (
<SortableContext
items={availableSounds.map(
items={filteredAvailableSounds.map(
sound => `available-sound-${sound.id}`,
)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2 max-h-96 overflow-y-auto">
{availableSounds.map(sound => (
{filteredAvailableSounds.map(sound => (
<AvailableSound
key={sound.id}
sound={sound}

View File

@@ -189,6 +189,53 @@ export function PlaylistsPage() {
}
}
const handleDeletePlaylist = async (playlist: Playlist) => {
// Protect main playlist from deletion
if (playlist.is_main) {
toast.error('The main playlist cannot be deleted')
return
}
// Check if playlist is deletable
if (!playlist.is_deletable) {
toast.error('This playlist cannot be deleted')
return
}
// Confirm deletion
const confirmMessage = `Are you sure you want to delete the playlist "${playlist.name}"?${
playlist.sound_count > 0
? `\n\nThis playlist contains ${playlist.sound_count} sound${playlist.sound_count !== 1 ? 's' : ''}. The sounds will not be deleted, only removed from this playlist.`
: ''
}\n\nThis action cannot be undone.`
if (!confirm(confirmMessage)) {
return
}
try {
await playlistsService.deletePlaylist(playlist.id)
toast.success(`Playlist "${playlist.name}" deleted successfully`)
// Remove the deleted playlist from the local state
setPlaylists(prevPlaylists =>
prevPlaylists.filter(p => p.id !== playlist.id)
)
// Update total count
setTotalCount(prev => prev - 1)
// If current page is now empty and not the first page, go to previous page
const remainingOnCurrentPage = playlists.length - 1
if (remainingOnCurrentPage === 0 && currentPage > 1) {
setCurrentPage(currentPage - 1)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to delete playlist'
toast.error(errorMessage)
}
}
const renderContent = () => {
if (loading) {
return <PlaylistsLoading />
@@ -209,6 +256,7 @@ export function PlaylistsPage() {
onEdit={handleEditPlaylist}
onSetCurrent={handleSetCurrent}
onFavoriteToggle={handleFavoriteToggle}
onDelete={handleDeletePlaylist}
/>
<AppPagination
currentPage={currentPage}

View File

@@ -100,11 +100,6 @@ export function SchedulersPage() {
setShowCreateDialog(false)
}
const handleTaskUpdated = (updatedTask: ScheduledTask) => {
setTasks(prev => prev.map(task =>
task.id === updatedTask.id ? updatedTask : task
))
}
const handleTaskDeleted = (taskId: number) => {
setTasks(prev => prev.filter(task => task.id !== taskId))
@@ -132,7 +127,6 @@ export function SchedulersPage() {
return (
<SchedulersTable
tasks={tasks}
onTaskUpdated={handleTaskUpdated}
onTaskDeleted={handleTaskDeleted}
/>
)

489
src/pages/SequencerPage.tsx Normal file
View File

@@ -0,0 +1,489 @@
import { TrackControls } from '@/components/sequencer/TrackControls'
import { TimelineControls } from '@/components/sequencer/TimelineControls'
import { SoundLibrary } from '@/components/sequencer/SoundLibrary'
import { SequencerCanvas } from '@/components/sequencer/SequencerCanvas'
import { DndContext, DragOverlay, type DragEndEvent, type DragStartEvent, PointerSensor, useSensors, useSensor } from '@dnd-kit/core'
import { useState, useRef, useCallback, useEffect } from 'react'
export interface Track {
id: string
name: string
sounds: PlacedSound[]
}
export interface PlacedSound {
id: string
soundId: number
name: string
duration: number // in milliseconds
startTime: number // in milliseconds
trackId: string
}
interface SequencerState {
tracks: Track[]
duration: number // in milliseconds
zoom: number // pixels per second
currentTime: number // in milliseconds
isPlaying: boolean
}
const INITIAL_DURATION = 30000 // 30 seconds in milliseconds
const INITIAL_ZOOM = 40 // 40 pixels per second
const MIN_ZOOM = 5
const MAX_ZOOM = 200
export function SequencerPage() {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
)
const [state, setState] = useState<SequencerState>({
tracks: [
{
id: 'track-1',
name: 'Track 1',
sounds: [],
},
],
duration: INITIAL_DURATION,
zoom: INITIAL_ZOOM,
currentTime: 0,
isPlaying: false,
})
const [draggedItem, setDraggedItem] = useState<any>(null)
const [dragOverInfo, setDragOverInfo] = useState<{trackId: string, x: number} | null>(null)
const [currentMousePos, setCurrentMousePos] = useState<{x: number, y: number} | null>(null)
const trackControlsRef = useRef<HTMLDivElement>(null)
const sequencerCanvasRef = useRef<HTMLDivElement>(null)
const handleDragStart = useCallback((event: DragStartEvent) => {
setDraggedItem(event.active.data.current)
// Start tracking mouse position globally
const handleMouseMove = (e: MouseEvent) => {
setCurrentMousePos({ x: e.clientX, y: e.clientY })
}
document.addEventListener('mousemove', handleMouseMove)
// Store cleanup function
;(window as any).dragMouseCleanup = () => {
document.removeEventListener('mousemove', handleMouseMove)
}
}, [])
// Calculate logical time intervals based on zoom level (shared with SequencerCanvas)
const getTimeIntervals = useCallback((zoom: number, duration: number) => {
const durationSeconds = duration / 1000
const minorIntervals: number[] = []
const majorIntervals: number[] = []
// Define logical interval progressions
const intervalSets = [
{ minor: 0.1, major: 1 }, // 0.1s minor, 1s major (mega zoomed in)
{ minor: 1, major: 5 }, // 1s minor, 5s major (very zoomed in)
{ minor: 5, major: 30 }, // 5s minor, 30s major
{ minor: 10, major: 60 }, // 10s minor, 1min major
{ minor: 30, major: 300 }, // 30s minor, 5min major
{ minor: 60, major: 600 }, // 1min minor, 10min major
{ minor: 300, major: 1800 }, // 5min minor, 30min major
{ minor: 600, major: 3600 } // 10min minor, 1h major
]
// Find appropriate interval set based on zoom level
// We want major intervals to be roughly 100-200px apart
const targetMajorSpacing = 150
let selectedIntervals = intervalSets[intervalSets.length - 1] // fallback to largest
for (const intervals of intervalSets) {
if (intervals.major * zoom >= targetMajorSpacing) {
selectedIntervals = intervals
break
}
}
// Generate minor intervals (every interval)
for (let i = 0; i * selectedIntervals.minor <= durationSeconds; i++) {
const time = i * selectedIntervals.minor
minorIntervals.push(time)
}
// Generate major intervals (at major boundaries)
for (let i = 0; i * selectedIntervals.major <= durationSeconds; i++) {
const time = i * selectedIntervals.major
majorIntervals.push(time)
}
return { minorIntervals, majorIntervals, minorInterval: selectedIntervals.minor, majorInterval: selectedIntervals.major }
}, [])
// Helper function to snap time to 100ms intervals
const snapToGrid = useCallback((timeInSeconds: number): number => {
const snapIntervalMs = 100 // 100ms snap interval
const timeInMs = Math.max(0, timeInSeconds * 1000) // Ensure non-negative
const snappedMs = Math.round(timeInMs / snapIntervalMs) * snapIntervalMs
return Math.max(0, snappedMs / 1000) // Convert back to seconds, ensure non-negative
}, [])
// Update drag over info based on current mouse position and over target
useEffect(() => {
if (draggedItem && currentMousePos && (draggedItem.type === 'sound' || draggedItem.type === 'placed-sound')) {
// Find which track the mouse is currently over
for (const track of state.tracks) {
const trackElement = document.getElementById(`track-${track.id}`)
if (trackElement) {
const rect = trackElement.getBoundingClientRect()
if (
currentMousePos.x >= rect.left &&
currentMousePos.x <= rect.right &&
currentMousePos.y >= rect.top &&
currentMousePos.y <= rect.bottom
) {
const rawX = currentMousePos.x - rect.left
// Apply 100ms snapping to the drag over position
const rawTimeSeconds = rawX / state.zoom
const snappedTimeSeconds = snapToGrid(rawTimeSeconds)
const snappedX = snappedTimeSeconds * state.zoom
setDragOverInfo({ trackId: track.id, x: Math.max(0, snappedX) })
return
}
}
}
// Mouse is not over any track
setDragOverInfo(null)
} else {
setDragOverInfo(null)
}
}, [draggedItem, currentMousePos, state.tracks, state.zoom, snapToGrid])
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
const dragData = active.data.current
const overData = over?.data.current
// Handle sound drop from library to track
if (dragData?.type === 'sound' && overData?.type === 'track') {
// Use precise drop position if available (dragOverInfo.x is already snapped)
let startTime = 0
if (dragOverInfo && dragOverInfo.trackId === overData.trackId) {
startTime = Math.max(0, (dragOverInfo.x / state.zoom) * 1000) // Convert to milliseconds
}
const soundDuration = dragData.sound.duration // Already in milliseconds
// Restrict placement to within track duration (all in milliseconds)
const maxStartTime = Math.max(0, state.duration - soundDuration)
startTime = Math.min(startTime, maxStartTime)
// Only proceed if the sound can fit within the track
if (startTime >= 0 && startTime + soundDuration <= state.duration) {
const newPlacedSound: PlacedSound = {
id: `placed-${Date.now()}-${Math.random()}`,
soundId: dragData.sound.id,
name: dragData.sound.name || dragData.sound.filename,
duration: soundDuration,
startTime,
trackId: overData.trackId,
}
setState(prev => ({
...prev,
tracks: prev.tracks.map(track =>
track.id === overData.trackId
? { ...track, sounds: [...track.sounds, newPlacedSound] }
: track
),
}))
}
}
// Handle moving placed sounds within tracks
if (dragData?.type === 'placed-sound' && overData?.type === 'track') {
// Use precise drop position if available (dragOverInfo.x is already snapped)
let startTime = dragData.startTime || 0
if (dragOverInfo && dragOverInfo.trackId === overData.trackId) {
startTime = Math.max(0, (dragOverInfo.x / state.zoom) * 1000) // Convert to milliseconds
}
// Restrict placement to within track duration (all in milliseconds)
const maxStartTime = Math.max(0, state.duration - dragData.duration)
startTime = Math.min(startTime, maxStartTime)
// Only proceed if the sound can fit within the track
if (startTime >= 0 && startTime + dragData.duration <= state.duration) {
const sourceTrackId = dragData.trackId
const targetTrackId = overData.trackId
setState(prev => ({
...prev,
tracks: prev.tracks.map(track => {
if (track.id === sourceTrackId && sourceTrackId === targetTrackId) {
// Moving within the same track - just update position
const updatedSound: PlacedSound = {
id: dragData.id,
soundId: dragData.soundId,
name: dragData.name,
duration: dragData.duration,
startTime,
trackId: targetTrackId,
}
return {
...track,
sounds: track.sounds.map(s =>
s.id === dragData.id ? updatedSound : s
),
}
} else if (track.id === sourceTrackId) {
// Remove from source track (different track move)
return {
...track,
sounds: track.sounds.filter(s => s.id !== dragData.id),
}
} else if (track.id === targetTrackId) {
// Add to target track (different track move)
const updatedSound: PlacedSound = {
id: dragData.id,
soundId: dragData.soundId,
name: dragData.name,
duration: dragData.duration,
startTime,
trackId: targetTrackId,
}
return {
...track,
sounds: [...track.sounds, updatedSound],
}
}
return track
}),
}))
}
}
// Clear state
setDraggedItem(null)
setDragOverInfo(null)
setCurrentMousePos(null)
// Clean up mouse tracking
if ((window as any).dragMouseCleanup) {
(window as any).dragMouseCleanup()
delete (window as any).dragMouseCleanup
}
}, [dragOverInfo, state.zoom])
const handleAddTrack = () => {
const newTrackNumber = state.tracks.length + 1
const newTrack: Track = {
id: `track-${Date.now()}`,
name: `Track ${newTrackNumber}`,
sounds: [],
}
setState(prev => ({ ...prev, tracks: [...prev.tracks, newTrack] }))
}
const handleRemoveTrack = (trackId: string) => {
setState(prev => ({
...prev,
tracks: prev.tracks.filter(track => track.id !== trackId),
}))
}
const handleUpdateTrackName = (trackId: string, name: string) => {
setState(prev => ({
...prev,
tracks: prev.tracks.map(track =>
track.id === trackId ? { ...track, name } : track
),
}))
}
// const handlePlay = () => {
// setState(prev => ({ ...prev, isPlaying: !prev.isPlaying }))
// }
// const handleStop = () => {
// setState(prev => ({ ...prev, isPlaying: false, currentTime: 0 }))
// }
// const handleReset = () => {
// setState(prev => ({ ...prev, currentTime: 0 }))
// }
const handleZoomChange = (value: number) => {
setState(prev => ({ ...prev, zoom: value }))
}
// Handle mouse wheel zoom with position centering
const handleZoomChangeWithPosition = (newZoom: number, mouseX?: number) => {
if (mouseX !== undefined && sequencerCanvasRef.current) {
const oldZoom = state.zoom
const currentScrollLeft = sequencerCanvasRef.current.scrollLeft
// Calculate the time position that the mouse is pointing at
const timeAtMouse = (currentScrollLeft + mouseX) / oldZoom
// Calculate what the new scroll position should be to keep the same time under the mouse
const newScrollLeft = timeAtMouse * newZoom - mouseX
setState(prev => ({ ...prev, zoom: newZoom }))
// Apply the new scroll position after the zoom change
requestAnimationFrame(() => {
if (sequencerCanvasRef.current) {
sequencerCanvasRef.current.scrollLeft = Math.max(0, newScrollLeft)
}
})
} else {
setState(prev => ({ ...prev, zoom: newZoom }))
}
}
const handleDurationChange = (duration: number) => {
setState(prev => ({ ...prev, duration }))
}
const handleRemoveSound = (soundId: string, trackId: string) => {
setState(prev => ({
...prev,
tracks: prev.tracks.map(track =>
track.id === trackId
? { ...track, sounds: track.sounds.filter(sound => sound.id !== soundId) }
: track
),
}))
}
const handleVerticalScroll = useCallback(() => {
if (trackControlsRef.current && sequencerCanvasRef.current) {
const canvasScrollTop = sequencerCanvasRef.current.scrollTop
if (Math.abs(trackControlsRef.current.scrollTop - canvasScrollTop) > 1) {
trackControlsRef.current.scrollTop = canvasScrollTop
}
}
}, [])
// Simple playhead animation
useEffect(() => {
if (state.isPlaying) {
const interval = setInterval(() => {
setState(prev => {
const newTime = prev.currentTime + 100 // Add 100ms every 100ms
if (newTime >= prev.duration) {
return { ...prev, currentTime: prev.duration, isPlaying: false }
}
return { ...prev, currentTime: newTime }
})
}, 100)
return () => clearInterval(interval)
}
}, [state.isPlaying, state.duration])
return (
<div className="h-screen w-screen flex flex-col overflow-hidden">
{/* Simple Header */}
<div className="h-12 bg-background border-b flex items-center px-4 flex-shrink-0">
<a href="/" className="text-sm text-muted-foreground hover:text-foreground">Dashboard</a>
<span className="mx-2 text-muted-foreground">/</span>
<span className="text-sm font-medium">Sequencer</span>
</div>
{/* Main Content */}
<div className="flex-1 flex overflow-hidden">
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{/* Left Sidebar - Sound Library */}
<div className="w-64 border-r bg-muted/30 flex flex-col flex-shrink-0">
<div className="p-4 h-full">
<SoundLibrary />
</div>
</div>
{/* Center Content - Tracks */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Timeline Controls */}
<div className="p-4 border-b bg-muted/30">
<TimelineControls
duration={state.duration}
zoom={state.zoom}
onDurationChange={handleDurationChange}
onZoomChange={handleZoomChange}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
/>
</div>
{/* Track Area */}
<div className="flex-1 flex overflow-hidden">
{/* Track Controls */}
<div className="w-32 border-r flex-shrink-0">
<TrackControls
ref={trackControlsRef}
tracks={state.tracks}
onAddTrack={handleAddTrack}
onRemoveTrack={handleRemoveTrack}
onUpdateTrackName={handleUpdateTrackName}
onScroll={handleVerticalScroll}
/>
</div>
{/* Sequencer Canvas */}
<div className="flex-1 overflow-hidden">
<SequencerCanvas
ref={sequencerCanvasRef}
tracks={state.tracks}
duration={state.duration}
zoom={state.zoom}
currentTime={state.currentTime}
isPlaying={state.isPlaying}
onScroll={handleVerticalScroll}
draggedItem={draggedItem}
dragOverInfo={dragOverInfo}
onRemoveSound={handleRemoveSound}
timeIntervals={getTimeIntervals(state.zoom, state.duration)}
onZoomChange={handleZoomChangeWithPosition}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
/>
</div>
</div>
</div>
<DragOverlay>
{draggedItem?.type === 'sound' ? (
<div className="p-3 border rounded-lg bg-card shadow-lg opacity-90">
<div className="flex items-start gap-2">
<div className="flex-shrink-0 mt-0.5">
{draggedItem.sound.type === 'SDB' && <span className="text-xs font-bold text-blue-500">SDB</span>}
{draggedItem.sound.type === 'TTS' && <span className="text-xs font-bold text-green-500">TTS</span>}
{draggedItem.sound.type === 'EXT' && <span className="text-xs font-bold text-purple-500">EXT</span>}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">
{draggedItem.sound.name || draggedItem.sound.filename}
</div>
</div>
</div>
</div>
) : draggedItem?.type === 'placed-sound' ? (
<div className="p-2 border rounded bg-primary/20 border-primary/40 shadow-lg opacity-90">
<div className="font-medium text-sm text-primary truncate">
{draggedItem.name}
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
</div>
)
}

View File

@@ -22,6 +22,7 @@ import { SOUND_EVENTS, soundEvents } from '@/lib/events'
import { useSocket } from '@/contexts/SocketContext'
import {
AlertCircle,
Filter,
Heart,
RefreshCw,
Search,
@@ -90,6 +91,7 @@ export function SoundsPage() {
const [sortBy, setSortBy] = useState<SoundSortField>('name')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false)
const [typeFilter, setTypeFilter] = useState<'all' | 'SDB' | 'TTS'>('all')
const handlePlaySound = async (sound: Sound) => {
// If WebSocket is connected, use WebSocket for immediate response
@@ -159,13 +161,18 @@ export function SoundsPage() {
try {
setLoading(true)
setError(null)
const sdbSounds = await soundsService.getSDBSounds({
// Determine types to filter by
const types = typeFilter === 'all' ? ['SDB', 'TTS'] : [typeFilter]
const sounds = await soundsService.getSounds({
types,
search: debouncedSearchQuery.trim() || undefined,
sort_by: sortBy,
sort_order: sortOrder,
favorites_only: showFavoritesOnly,
})
setSounds(sdbSounds)
setSounds(sounds)
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to fetch sounds'
@@ -189,7 +196,7 @@ export function SoundsPage() {
useEffect(() => {
fetchSounds()
}, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly])
}, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly, typeFilter])
// Listen for sound_played events and update play_count
useEffect(() => {
@@ -290,7 +297,9 @@ export function SoundsPage() {
<p className="text-muted-foreground">
{showFavoritesOnly
? 'You haven\'t favorited any sounds yet. Click the heart icon on sounds to add them to your favorites.'
: 'No SDB type sounds are available in your library.'
: typeFilter === 'all'
? 'No sounds are available in your library.'
: `No ${typeFilter} type sounds are available in your library.`
}
</p>
</div>
@@ -298,7 +307,7 @@ export function SoundsPage() {
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sounds.map((sound, idx) => (
<SoundCard
key={sound.id}
@@ -362,6 +371,21 @@ export function SoundsPage() {
</div>
<div className="flex gap-2">
<Select
value={typeFilter}
onValueChange={value => setTypeFilter(value as 'all' | 'SDB' | 'TTS')}
>
<SelectTrigger className="w-[160px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="SDB">Soundboard</SelectItem>
<SelectItem value="TTS">TTS</SelectItem>
</SelectContent>
</Select>
<Select
value={sortBy}
onValueChange={value => setSortBy(value as SoundSortField)}

201
src/pages/TTSPage.tsx Normal file
View File

@@ -0,0 +1,201 @@
import { AppLayout } from '@/components/AppLayout'
import { AppPagination } from '@/components/AppPagination'
import { CreateTTSDialog } from '@/components/tts/CreateTTSDialog'
import { TTSHeader } from '@/components/tts/TTSHeader'
import {
TTSEmpty,
TTSError,
TTSLoading,
} from '@/components/tts/TTSLoadingStates'
import { TTSTable } from '@/components/tts/TTSTable'
import {
type TTSResponse,
type TTSSortField,
type TTSSortOrder,
ttsService,
} from '@/lib/api/services/tts'
import { TTS_EVENTS, ttsEvents } from '@/lib/events'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
export function TTSPage() {
const [ttsHistory, setTTSHistory] = useState<TTSResponse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Search and sorting state
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<TTSSortField>('created_at')
const [sortOrder, setSortOrder] = useState<TTSSortOrder>('desc')
// Pagination state
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [totalCount, setTotalCount] = useState(0)
const [pageSize, setPageSize] = useState(10)
// Create TTS dialog state
const [showCreateDialog, setShowCreateDialog] = useState(false)
// Debounce search query
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchQuery(searchQuery)
}, 300)
return () => clearTimeout(handler)
}, [searchQuery])
const fetchTTSHistory = useCallback(async () => {
try {
setLoading(true)
setError(null)
const response = await ttsService.getTTSHistory({
search: debouncedSearchQuery.trim() || undefined,
sort_by: sortBy,
sort_order: sortOrder,
page: currentPage,
limit: pageSize,
})
setTTSHistory(response.tts)
setTotalPages(response.total_pages)
setTotalCount(response.total)
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to fetch TTS history'
setError(errorMessage)
toast.error(errorMessage)
} finally {
setLoading(false)
}
}, [debouncedSearchQuery, sortBy, sortOrder, currentPage, pageSize])
useEffect(() => {
fetchTTSHistory()
}, [fetchTTSHistory])
// Reset to page 1 when filters change
useEffect(() => {
if (currentPage !== 1) {
setCurrentPage(1)
}
}, [debouncedSearchQuery, sortBy, sortOrder, pageSize])
// Listen for TTS events to refresh the list
useEffect(() => {
const handleTTSCompleted = () => {
fetchTTSHistory()
}
const handleTTSFailed = () => {
fetchTTSHistory()
}
const handleTTSCreated = () => {
fetchTTSHistory()
}
// Subscribe to TTS events
ttsEvents.on(TTS_EVENTS.TTS_COMPLETED, handleTTSCompleted)
ttsEvents.on(TTS_EVENTS.TTS_FAILED, handleTTSFailed)
ttsEvents.on(TTS_EVENTS.TTS_CREATED, handleTTSCreated)
return () => {
// Cleanup event listeners
ttsEvents.off(TTS_EVENTS.TTS_COMPLETED, handleTTSCompleted)
ttsEvents.off(TTS_EVENTS.TTS_FAILED, handleTTSFailed)
ttsEvents.off(TTS_EVENTS.TTS_CREATED, handleTTSCreated)
}
}, [fetchTTSHistory])
const handlePageChange = (page: number) => {
setCurrentPage(page)
}
const handlePageSizeChange = (size: number) => {
setPageSize(size)
setCurrentPage(1) // Reset to first page when changing page size
}
const handleTTSDeleted = (ttsId: number) => {
// Remove the deleted TTS from the current list
setTTSHistory(prev => prev.filter(tts => tts.id !== ttsId))
// Update total count
setTotalCount(prev => prev - 1)
// If current page is now empty and not the first page, go to previous page
const remainingOnCurrentPage = ttsHistory.length - 1
if (remainingOnCurrentPage === 0 && currentPage > 1) {
setCurrentPage(currentPage - 1)
}
// Refresh the full list to ensure accuracy
fetchTTSHistory()
}
const renderContent = () => {
if (loading) {
return <TTSLoading />
}
if (error) {
return <TTSError error={error} onRetry={fetchTTSHistory} />
}
if (!ttsHistory || ttsHistory.length === 0) {
return <TTSEmpty searchQuery={searchQuery} />
}
return (
<div className="space-y-4">
<TTSTable
ttsHistory={ttsHistory}
onTTSDeleted={handleTTSDeleted}
/>
<AppPagination
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
itemName="TTS generations"
/>
</div>
)
}
return (
<AppLayout
breadcrumb={{
items: [{ label: 'Dashboard', href: '/' }, { label: 'Text to Speech' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
<TTSHeader
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
sortBy={sortBy}
onSortByChange={setSortBy}
sortOrder={sortOrder}
onSortOrderChange={setSortOrder}
onRefresh={fetchTTSHistory}
onCreateClick={() => setShowCreateDialog(true)}
loading={loading}
error={error}
ttsCount={totalCount}
/>
<CreateTTSDialog
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
/>
{renderContent()}
</div>
</AppLayout>
)
}

View File

@@ -25,6 +25,61 @@ export default defineConfig({
build: {
outDir: 'dist',
sourcemap: false, // Disable source maps in production for security
rollupOptions: {
output: {
manualChunks: {
// React core
'react': ['react', 'react-dom', 'react-router'],
// UI library - Radix UI components
'radix-ui': [
'@radix-ui/react-avatar',
'@radix-ui/react-checkbox',
'@radix-ui/react-context-menu',
'@radix-ui/react-dialog',
'@radix-ui/react-dropdown-menu',
'@radix-ui/react-label',
'@radix-ui/react-popover',
'@radix-ui/react-progress',
'@radix-ui/react-scroll-area',
'@radix-ui/react-select',
'@radix-ui/react-separator',
'@radix-ui/react-slider',
'@radix-ui/react-slot',
'@radix-ui/react-switch',
'@radix-ui/react-tabs',
'@radix-ui/react-tooltip',
],
// Drag and drop
'dnd-kit': [
'@dnd-kit/core',
'@dnd-kit/sortable',
'@dnd-kit/utilities',
],
// Utilities
'utils': [
'clsx',
'tailwind-merge',
'class-variance-authority',
'date-fns',
],
// Other libraries
'misc': [
'recharts',
'socket.io-client',
'sonner',
'next-themes',
'cmdk',
'react-day-picker',
'@number-flow/react',
'lucide-react',
],
},
},
},
},
// Preview server configuration (for testing built version)
preview: {