diff --git a/.gitignore b/.gitignore
index 4dcc65d3..b103c72c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,8 @@ static/video_editor/videos/sample-video-37s.mp4
.DS_Store
static/video_editor/videos/sample-video-10m.mp4
static/video_editor/videos/sample-video-10s.mp4
+frontend-tools/video-js/public/videos/sample-video-white.mp4
+frontend-tools/video-editor/client/public/videos/sample-video.mp3
+frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
+static/chapters_editor/videos/sample-video.mp3
+static/video_editor/videos/sample-video.mp3
diff --git a/.prettierignore b/.prettierignore
index f59ec20a..919dd65f 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1 +1,3 @@
-*
\ No newline at end of file
+/templates/cms/*
+/templates/*.html
+*.scss
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 00000000..d4b64fae
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,21 @@
+{
+ "semi": true,
+ "singleQuote": true,
+ "printWidth": 120,
+ "tabWidth": 4,
+ "useTabs": false,
+ "trailingComma": "es5",
+ "bracketSpacing": true,
+ "bracketSameLine": false,
+ "arrowParens": "always",
+ "endOfLine": "lf",
+ "embeddedLanguageFormatting": "auto",
+ "overrides": [
+ {
+ "files": ["*.css", "*.scss"],
+ "options": {
+ "singleQuote": false
+ }
+ }
+ ]
+}
diff --git a/cms/version.py b/cms/version.py
index c8f916e2..8f5a37ae 100644
--- a/cms/version.py
+++ b/cms/version.py
@@ -1 +1 @@
-VERSION = "6.7.1"
+VERSION = "7.0.0"
diff --git a/deploy/docker/entrypoint.sh b/deploy/docker/entrypoint.sh
index 7925836d..4e80e4c4 100755
--- a/deploy/docker/entrypoint.sh
+++ b/deploy/docker/entrypoint.sh
@@ -30,7 +30,8 @@ fi
# We should do this only for folders that have a different owner, since it is an expensive operation
# Also ignoring .git folder to fix this issue https://github.com/mediacms-io/mediacms/issues/934
-find /home/mediacms.io/mediacms ! \( -path "*.git*" \) -exec chown www-data:$TARGET_GID {} +
+# Exclude package-lock.json files that may not exist or be removed during frontend setup
+find /home/mediacms.io/mediacms ! \( -path "*.git*" -o -name "package-lock.json" \) -exec chown www-data:$TARGET_GID {} + 2>/dev/null || true
chmod +x /home/mediacms.io/mediacms/deploy/docker/start.sh /home/mediacms.io/mediacms/deploy/docker/prestart.sh
diff --git a/deploy/scripts/build_and_deploy.sh b/deploy/scripts/build_and_deploy.sh
index 53e6e387..fb4abdd2 100644
--- a/deploy/scripts/build_and_deploy.sh
+++ b/deploy/scripts/build_and_deploy.sh
@@ -1,5 +1,6 @@
#!/bin/bash
# This script builds the video editor package and deploys the frontend assets to the static directory.
+# How to run: sh deploy/scripts/build_and_deploy.sh
# Exit on any error
set -e
@@ -12,9 +13,21 @@ cd frontend-tools/video-editor
yarn build:django
cd ../../
+# Build chapter editor package
+echo "Building chapters editor package..."
+cd frontend-tools/chapters-editor
+yarn build:django
+cd ../../
+
+# Build video js package
+echo "Building video js package..."
+cd frontend-tools/video-js
+yarn build:django
+cd ../../
+
# Run npm build in the frontend container
echo "Building frontend assets..."
-docker compose -f docker-compose-dev.yaml exec frontend npm run dist
+docker compose -f docker-compose/docker-compose-dev-updated.yaml exec frontend npm run dist
# Copy static assets to the static directory
echo "Copying static assets..."
@@ -22,6 +35,6 @@ cp -r frontend/dist/static/* static/
# Restart the web service
echo "Restarting web service..."
-docker compose -f docker-compose-dev.yaml restart web
+docker compose -f docker-compose/docker-compose-dev-updated.yaml restart web
echo "Build and deployment completed successfully!"
\ No newline at end of file
diff --git a/docker-compose/docker-compose-dev-updated.yaml b/docker-compose/docker-compose-dev-updated.yaml
index f055661f..32baa42e 100644
--- a/docker-compose/docker-compose-dev-updated.yaml
+++ b/docker-compose/docker-compose-dev-updated.yaml
@@ -33,55 +33,35 @@ services:
volumes:
- ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/
- frontend_node_modules:/home/mediacms.io/mediacms/frontend/node_modules
- - player_node_modules:/home/mediacms.io/mediacms/frontend/packages/player/node_modules
- scripts_node_modules:/home/mediacms.io/mediacms/frontend/packages/scripts/node_modules
- - npm_global:/home/node/.npm-global
+ - npm_cache:/home/node/.npm
working_dir: /home/mediacms.io/mediacms/frontend/
command: >
bash -c "
- echo 'Setting up npm global directory...' &&
- mkdir -p /home/node/.npm-global &&
- chown -R node:node /home/node/.npm-global &&
- echo 'Setting up permissions...' &&
- chown -R node:node /home/mediacms.io/mediacms/frontend &&
- echo 'Cleaning up node_modules...' &&
- find /home/mediacms.io/mediacms/frontend/node_modules -mindepth 1 -delete 2>/dev/null || true &&
- find /home/mediacms.io/mediacms/frontend/packages/player/node_modules -mindepth 1 -delete 2>/dev/null || true &&
- find /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules -mindepth 1 -delete 2>/dev/null || true &&
- chown -R node:node /home/mediacms.io/mediacms/frontend/node_modules &&
- chown -R node:node /home/mediacms.io/mediacms/frontend/packages/player/node_modules &&
- chown -R node:node /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules &&
- echo 'Switching to node user...' &&
- su node -c '
- export NPM_CONFIG_PREFIX=/home/node/.npm-global &&
- echo \"Setting up frontend...\" &&
- rm -f package-lock.json &&
- rm -f packages/player/package-lock.json &&
- rm -f packages/scripts/package-lock.json &&
- echo \"Installing dependencies...\" &&
- npm install --legacy-peer-deps &&
- echo \"Setting up workspaces...\" &&
- npm install -g npm@latest &&
+ echo 'Checking dependencies...' &&
+ if [ ! -f node_modules/.install-complete ]; then
+ echo 'First-time setup or dependencies changed, installing...' &&
+ npm install --legacy-peer-deps --cache /home/node/.npm &&
cd packages/scripts &&
- npm install --legacy-peer-deps &&
- npm install rollup@2.79.1 --save-dev --legacy-peer-deps &&
- npm install typescript@4.9.5 --save-dev --legacy-peer-deps &&
- npm install tslib@2.6.2 --save --legacy-peer-deps &&
- npm install rollup-plugin-typescript2@0.34.1 --save-dev --legacy-peer-deps &&
- npm install --legacy-peer-deps &&
+ npm install --legacy-peer-deps --cache /home/node/.npm &&
npm run build &&
cd ../.. &&
- cd packages/player &&
- npm install --legacy-peer-deps &&
- npm run build &&
- cd ../.. &&
- echo \"Starting development server...\" &&
- npm run start
- '"
+ touch node_modules/.install-complete &&
+ echo 'Dependencies installed successfully'
+ else
+ echo 'Dependencies already installed, skipping installation...' &&
+ if [ ! -d packages/scripts/dist ]; then
+ echo 'Building scripts package...' &&
+ cd packages/scripts &&
+ npm run build &&
+ cd ../..
+ fi
+ fi &&
+ echo 'Starting development server...' &&
+ npm run start
+ "
env_file:
- ${PWD}/frontend/.env
- environment:
- - NPM_CONFIG_PREFIX=/home/node/.npm-global
ports:
- "8088:8088"
depends_on:
@@ -140,6 +120,5 @@ services:
volumes:
frontend_node_modules:
- player_node_modules:
scripts_node_modules:
- npm_global:
+ npm_cache:
diff --git a/files/methods.py b/files/methods.py
index 9478c8e1..8ac8c94b 100644
--- a/files/methods.py
+++ b/files/methods.py
@@ -604,7 +604,7 @@ def handle_video_chapters(media, chapters):
else:
video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters)
- return media.chapter_data
+ return {'chapters': media.chapter_data}
def change_media_owner(media_id, new_user):
diff --git a/files/models/media.py b/files/models/media.py
index 65f1c5e9..01303875 100644
--- a/files/models/media.py
+++ b/files/models/media.py
@@ -630,7 +630,7 @@ class Media(models.Model):
@property
def trim_video_url(self):
- if self.media_type not in ["video"]:
+ if self.media_type not in ["video", "audio"]:
return None
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()
@@ -642,7 +642,7 @@ class Media(models.Model):
@property
def trim_video_path(self):
- if self.media_type not in ["video"]:
+ if self.media_type not in ["video", "audio"]:
return None
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()
diff --git a/files/models/video_data.py b/files/models/video_data.py
index 489244f6..6f52d7dc 100644
--- a/files/models/video_data.py
+++ b/files/models/video_data.py
@@ -12,40 +12,19 @@ class VideoChapterData(models.Model):
class Meta:
unique_together = ['media']
- def save(self, *args, **kwargs):
- from .. import tasks
-
- is_new = self.pk is None
- if is_new or (not is_new and self._check_data_changed()):
- super().save(*args, **kwargs)
- tasks.produce_video_chapters.delay(self.pk)
- else:
- super().save(*args, **kwargs)
-
- def _check_data_changed(self):
- if self.pk:
- old_instance = VideoChapterData.objects.get(pk=self.pk)
- return old_instance.data != self.data
- return False
-
@property
def chapter_data(self):
# ensure response is consistent
data = []
- for item in self.data:
- if item.get("start") and item.get("title"):
- thumbnail = item.get("thumbnail")
- if thumbnail:
- thumbnail = helpers.url_from_path(thumbnail)
- else:
- thumbnail = "static/images/chapter_default.jpg"
- data.append(
- {
- "start": item.get("start"),
- "title": item.get("title"),
- "thumbnail": thumbnail,
+ if self.data and isinstance(self.data, list):
+ for item in self.data:
+ if item.get("startTime") and item.get("endTime") and item.get("chapterTitle"):
+ chapter_item = {
+ 'startTime': item.get("startTime"),
+ 'endTime': item.get("endTime"),
+ 'chapterTitle': item.get("chapterTitle"),
}
- )
+ data.append(chapter_item)
return data
diff --git a/files/tasks.py b/files/tasks.py
index 5d6eca12..586075ae 100644
--- a/files/tasks.py
+++ b/files/tasks.py
@@ -52,7 +52,6 @@ from .models import (
Subtitle,
Tag,
TranscriptionRequest,
- VideoChapterData,
VideoTrimRequest,
)
@@ -950,45 +949,6 @@ def update_encoding_size(encoding_id):
return False
-@task(name="produce_video_chapters", queue="short_tasks")
-def produce_video_chapters(chapter_id):
- # this is not used
- return False
- chapter_object = VideoChapterData.objects.filter(id=chapter_id).first()
- if not chapter_object:
- return False
-
- media = chapter_object.media
- video_path = media.media_file.path
- output_folder = media.video_chapters_folder
-
- chapters = chapter_object.data
-
- width = 336
- height = 188
-
- if not os.path.exists(output_folder):
- os.makedirs(output_folder)
-
- results = []
-
- for i, chapter in enumerate(chapters):
- timestamp = chapter["start"]
- title = chapter["title"]
-
- output_filename = f"thumbnail_{i:02d}.jpg" # noqa
- output_path = os.path.join(output_folder, output_filename)
-
- command = [settings.FFMPEG_COMMAND, "-y", "-ss", str(timestamp), "-i", video_path, "-vframes", "1", "-q:v", "2", "-s", f"{width}x{height}", output_path]
- ret = run_command(command) # noqa
- if os.path.exists(output_path) and get_file_type(output_path) == "image":
- results.append({"start": timestamp, "title": title, "thumbnail": output_path})
-
- chapter_object.data = results
- chapter_object.save(update_fields=["data"])
- return True
-
-
@task(name="post_trim_action", queue="short_tasks", soft_time_limit=600)
def post_trim_action(friendly_token):
"""Perform post-processing actions after video trimming
diff --git a/files/views/pages.py b/files/views/pages.py
index 9041208f..d1b4aeff 100644
--- a/files/views/pages.py
+++ b/files/views/pages.py
@@ -244,8 +244,6 @@ def history(request):
@csrf_exempt
@login_required
def video_chapters(request, friendly_token):
- # this is not ready...
- return False
if not request.method == "POST":
return HttpResponseRedirect("/")
@@ -258,20 +256,26 @@ def video_chapters(request, friendly_token):
return HttpResponseRedirect("/")
try:
- data = json.loads(request.body)["chapters"]
+ request_data = json.loads(request.body)
+ data = request_data.get("chapters")
+ if data is None:
+ return JsonResponse({'success': False, 'error': 'Request must contain "chapters" array'}, status=400)
+
chapters = []
for _, chapter_data in enumerate(data):
- start_time = chapter_data.get('start')
- title = chapter_data.get('title')
- if start_time and title:
+ start_time = chapter_data.get('startTime')
+ end_time = chapter_data.get('endTime')
+ chapter_title = chapter_data.get('chapterTitle')
+ if start_time and end_time and chapter_title:
chapters.append(
{
- 'start': start_time,
- 'title': title,
+ 'startTime': start_time,
+ 'endTime': end_time,
+ 'chapterTitle': chapter_title,
}
)
except Exception as e: # noqa
- return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with start and title'}, status=400)
+ return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with startTime, endTime, chapterTitle'}, status=400)
ret = handle_video_chapters(media, chapters)
@@ -358,8 +362,6 @@ def publish_media(request):
@login_required
def edit_chapters(request):
"""Edit chapters"""
- # not implemented yet
- return False
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
@@ -371,10 +373,11 @@ def edit_chapters(request):
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
+ chapters = media.chapter_data
return render(
request,
"cms/edit_chapters.html",
- {"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token},
+ {"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token, "chapters": chapters},
)
@@ -426,7 +429,7 @@ def edit_video(request):
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
- if not media.media_type == "video":
+ if media.media_type not in ["video", "audio"]:
messages.add_message(request, messages.INFO, "Media is not video")
return HttpResponseRedirect(media.get_absolute_url())
diff --git a/frontend-tools/chapters-editor/.gitignore b/frontend-tools/chapters-editor/.gitignore
new file mode 100644
index 00000000..1b13e1f8
--- /dev/null
+++ b/frontend-tools/chapters-editor/.gitignore
@@ -0,0 +1,15 @@
+node_modules
+dist
+.DS_Store
+server/public
+vite.config.ts.*
+*.tar.gz
+yt.readme.md
+client/public/videos/sample-video.mp4
+client/public/videos/sample-video-30s.mp4
+client/public/videos/sample-video-37s.mp4
+videos/sample-video-37s.mp4
+client/public/videos/sample-video-30s.mp4
+client/public/videos/sample-video-1.mp4
+client/public/videos/sample-video-10m.mp4
+client/public/videos/sample-video-10s.mp4
diff --git a/frontend-tools/chapters-editor/.prettierignore b/frontend-tools/chapters-editor/.prettierignore
new file mode 100644
index 00000000..e69de29b
diff --git a/frontend-tools/chapters-editor/.vscode/settings.json b/frontend-tools/chapters-editor/.vscode/settings.json
new file mode 100644
index 00000000..13734cb4
--- /dev/null
+++ b/frontend-tools/chapters-editor/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true,
+ "prettier.configPath": ".prettierrc"
+}
diff --git a/frontend-tools/chapters-editor/README.md b/frontend-tools/chapters-editor/README.md
new file mode 100644
index 00000000..7237efd0
--- /dev/null
+++ b/frontend-tools/chapters-editor/README.md
@@ -0,0 +1,255 @@
+# MediaCMS Chapters Editor
+
+A modern browser-based chapter editing tool built with React and TypeScript that integrates with MediaCMS. The Chapters Editor allows users to create, manage, and edit video chapters with precise timing controls and an intuitive timeline interface.
+
+## Features
+
+- 📑 Create and manage video chapters with custom titles
+- ⏱️ Precise timestamp controls for chapter start and end points
+- ✂️ Split chapters and reorganize content
+- 👁️ Preview chapters with jump-to navigation
+- 🔄 Undo/redo support for all editing operations
+- 🏷️ Chapter metadata editing (titles, descriptions)
+- 💾 Save chapter data directly to MediaCMS
+- 🎯 Timeline-based chapter visualization
+- 📱 Responsive design for desktop and mobile
+
+## Use Cases
+
+- **Educational Content**: Add chapters to lectures and tutorials for better navigation
+- **Entertainment**: Create chapters for movies, shows, or long-form content
+- **Documentation**: Organize training videos and documentation with logical sections
+- **Accessibility**: Improve content accessibility with structured navigation
+
+## Tech Stack
+
+- React 18
+- TypeScript
+- Vite
+
+## Installation
+
+### Prerequisites
+
+- Node.js (v20+) - Use `nvm use 20` if you have nvm installed
+- Yarn or npm package manager
+
+### Setup
+
+```bash
+# Navigate to the Chapters Editor directory
+cd frontend-tools/chapters-editor
+
+# Install dependencies with Yarn
+yarn install
+
+# Or with npm
+npm install
+```
+
+## Development
+
+The Chapters Editor can be run in two modes:
+
+### Standalone Development Mode
+
+This starts a local development server with hot reloading:
+
+```bash
+# Start the development server with Yarn
+yarn dev
+
+# Or with npm
+npm run dev
+```
+
+### Frontend-only Development Mode
+
+If you want to work only on the frontend with MediaCMS backend:
+
+```bash
+# Start frontend-only development with Yarn
+yarn dev:frontend
+
+# Or with npm
+npm run dev:frontend
+```
+
+## Building
+
+### For MediaCMS Integration
+
+To build the Chapters Editor for integration with MediaCMS:
+
+```bash
+# Build for Django integration with Yarn
+yarn build:django
+
+# Or with npm
+npm run build:django
+```
+
+This will compile the editor and place the output in the MediaCMS static directory.
+
+### Standalone Build
+
+To build the editor as a standalone application:
+
+```bash
+# Build for production with Yarn
+yarn build
+
+# Or with npm
+npm run build
+```
+
+## Deployment
+
+To deploy the Chapters Editor, you can use the build and deploy script (recommended):
+
+```bash
+# Run the deployment script
+sh deploy/scripts/build_and_deploy.sh
+```
+
+The build script handles all necessary steps for compiling and deploying the editor to MediaCMS.
+
+You can also deploy manually after building:
+
+```bash
+# With Yarn
+yarn deploy
+
+# Or with npm
+npm run deploy
+```
+
+## Project Structure
+
+- `/client` - Frontend React application
+ - `/src` - Source code
+ - `/components` - React components for chapter editing
+ - `/hooks` - Custom React hooks for chapter management
+ - `/lib` - Utility functions and helpers
+ - `/services` - API services for MediaCMS integration
+ - `/styles` - CSS and style definitions
+- `/shared` - Shared TypeScript types and utilities
+
+## API Integration
+
+The Chapters Editor interfaces with MediaCMS through a set of API endpoints for:
+
+- Retrieving video metadata and existing chapters
+- Saving chapter data (timestamps, titles, descriptions)
+- Validating chapter structure and timing
+- Integration with MediaCMS user permissions
+
+### Chapter Data Format
+
+Chapters are stored in the following format:
+
+```json
+{
+ "chapters": [
+ {
+ "id": "chapter-1",
+ "title": "Introduction",
+ "startTime": 0,
+ "endTime": 120,
+ "description": "Opening remarks and overview"
+ },
+ {
+ "id": "chapter-2",
+ "title": "Main Content",
+ "startTime": 120,
+ "endTime": 600,
+ "description": "Core educational material"
+ }
+ ]
+}
+```
+
+## Code Formatting
+
+To automatically format all source files using [Prettier](https://prettier.io):
+
+```bash
+# Format all code in the src directory
+npx prettier --write client/src/
+
+# Or format specific file types
+npx prettier --write "client/src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"
+```
+
+You can also add this as a script in `package.json`:
+
+```json
+"scripts": {
+ "format": "prettier --write client/src/"
+}
+```
+
+Then run:
+
+```bash
+yarn format
+# or
+npm run format
+```
+
+## Testing
+
+Run the test suite to ensure Chapters Editor functionality:
+
+```bash
+# Run tests with Yarn
+yarn test
+
+# Or with npm
+npm test
+
+# Run tests in watch mode
+yarn test:watch
+npm run test:watch
+```
+
+## Contributing
+
+1. Fork the repository
+2. Create a feature branch: `git checkout -b feature/chapter-enhancement`
+3. Make your changes and add tests
+4. Run the formatter: `yarn format`
+5. Run tests: `yarn test`
+6. Commit your changes: `git commit -m "Add chapter enhancement"`
+7. Push to the branch: `git push origin feature/chapter-enhancement`
+8. Submit a pull request
+
+## Troubleshooting
+
+### Common Issues
+
+**Chapter timestamps not saving**: Ensure the MediaCMS backend API is accessible and user has proper permissions.
+
+**Timeline not displaying correctly**: Check browser console for JavaScript errors and ensure video file is properly loaded.
+
+**Performance issues with long videos**: The editor is optimized for videos up to 2 hours. For longer content, consider splitting into multiple files.
+
+### Debug Mode
+
+Enable debug mode for detailed logging:
+
+```bash
+# Start with debug logging
+DEBUG=true yarn dev
+```
+
+## Browser Support
+
+- Chrome/Chromium 90+
+- Firefox 88+
+- Safari 14+
+- Edge 90+
+
+## License
+
+This project is licensed under the same license as MediaCMS. See the main MediaCMS repository for license details.
diff --git a/frontend-tools/chapters-editor/client/index.html b/frontend-tools/chapters-editor/client/index.html
new file mode 100644
index 00000000..ce800f49
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/index.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+ Chapters Editor
+
+
+
+
+
+
+
+
diff --git a/frontend-tools/chapters-editor/client/public/audio-poster.jpg b/frontend-tools/chapters-editor/client/public/audio-poster.jpg
new file mode 100644
index 00000000..0c6bb5c1
Binary files /dev/null and b/frontend-tools/chapters-editor/client/public/audio-poster.jpg differ
diff --git a/frontend-tools/chapters-editor/client/src/App.tsx b/frontend-tools/chapters-editor/client/src/App.tsx
new file mode 100644
index 00000000..1fefad96
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/App.tsx
@@ -0,0 +1,184 @@
+import { formatDetailedTime } from './lib/timeUtils';
+import logger from './lib/logger';
+import VideoPlayer from '@/components/VideoPlayer';
+import TimelineControls from '@/components/TimelineControls';
+import EditingTools from '@/components/EditingTools';
+import ClipSegments from '@/components/ClipSegments';
+import MobilePlayPrompt from '@/components/IOSPlayPrompt';
+import useVideoChapters from '@/hooks/useVideoChapters';
+import { useEffect } from 'react';
+
+const App = () => {
+ const {
+ videoRef,
+ currentTime,
+ duration,
+ isPlaying,
+ setIsPlaying,
+ isMuted,
+ trimStart,
+ trimEnd,
+ splitPoints,
+ zoomLevel,
+ clipSegments,
+ selectedSegmentId,
+ hasUnsavedChanges,
+ historyPosition,
+ history,
+ handleTrimStartChange,
+ handleTrimEndChange,
+ handleZoomChange,
+ handleMobileSafeSeek,
+ handleSplit,
+ handleReset,
+ handleUndo,
+ handleRedo,
+ toggleMute,
+ handleSegmentUpdate,
+ handleChapterSave,
+ handleSelectedSegmentChange,
+ isMobile,
+ videoInitialized,
+ setVideoInitialized,
+ initializeSafariIfNeeded,
+ } = useVideoChapters();
+
+ const handlePlay = async () => {
+ if (!videoRef.current) return;
+
+ const video = videoRef.current;
+
+ // If already playing, just pause the video
+ if (isPlaying) {
+ video.pause();
+ setIsPlaying(false);
+ logger.debug('Video paused');
+ return;
+ }
+
+ // Safari: Try to initialize if needed before playing
+ if (duration === 0) {
+ const initialized = await initializeSafariIfNeeded();
+ if (initialized) {
+ // Wait a moment for initialization to complete
+ setTimeout(() => handlePlay(), 200);
+ return;
+ }
+ }
+
+ // Start playing - no boundary checking, play through entire timeline
+ video
+ .play()
+ .then(() => {
+ setIsPlaying(true);
+ setVideoInitialized(true);
+ logger.debug('Continuous playback started from:', formatDetailedTime(video.currentTime));
+ })
+ .catch((err) => {
+ console.error('Error playing video:', err);
+ });
+ };
+
+ // Handle keyboard shortcuts
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ // Don't handle keyboard shortcuts if user is typing in an input field
+ const target = event.target as HTMLElement;
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
+ return;
+ }
+
+ switch (event.code) {
+ case 'Space':
+ event.preventDefault(); // Prevent default spacebar behavior (scrolling, button activation)
+ handlePlay();
+ break;
+ case 'ArrowLeft':
+ event.preventDefault();
+ if (videoRef.current) {
+ const newTime = Math.max(currentTime - 10, 0);
+ handleMobileSafeSeek(newTime);
+ logger.debug('Jumped backward 10 seconds to:', formatDetailedTime(newTime));
+ }
+ break;
+ case 'ArrowRight':
+ event.preventDefault();
+ if (videoRef.current) {
+ const newTime = Math.min(currentTime + 10, duration);
+ handleMobileSafeSeek(newTime);
+ logger.debug('Jumped forward 10 seconds to:', formatDetailedTime(newTime));
+ }
+ break;
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [handlePlay, handleMobileSafeSeek, currentTime, duration, videoRef]);
+
+ return (
+
+
+
+
+ {/* Video Player */}
+
+
+ {/* Editing Tools */}
+ 0}
+ canRedo={historyPosition < history.length - 1}
+ />
+
+ {/* Timeline Controls */}
+
+
+ {/* Clip Segments */}
+
+
+
+ );
+};
+
+export default App;
diff --git a/frontend-tools/chapters-editor/client/src/assets/audioPosterUrl.ts b/frontend-tools/chapters-editor/client/src/assets/audioPosterUrl.ts
new file mode 100644
index 00000000..1c13a96b
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/assets/audioPosterUrl.ts
@@ -0,0 +1,6 @@
+// Import the audio poster image as a module
+// Vite will handle this and provide the correct URL
+import audioPosterJpg from '../../public/audio-poster.jpg';
+
+export const AUDIO_POSTER_URL = audioPosterJpg;
+
diff --git a/frontend-tools/chapters-editor/client/src/assets/pause-icon.svg b/frontend-tools/chapters-editor/client/src/assets/pause-icon.svg
new file mode 100644
index 00000000..777ec21d
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/assets/pause-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend-tools/chapters-editor/client/src/assets/play-from-beginning-icon.svg b/frontend-tools/chapters-editor/client/src/assets/play-from-beginning-icon.svg
new file mode 100644
index 00000000..8d4a4640
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/assets/play-from-beginning-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend-tools/chapters-editor/client/src/assets/play-from-start-icon.svg b/frontend-tools/chapters-editor/client/src/assets/play-from-start-icon.svg
new file mode 100644
index 00000000..3e298784
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/assets/play-from-start-icon.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/frontend-tools/chapters-editor/client/src/assets/play-icon.svg b/frontend-tools/chapters-editor/client/src/assets/play-icon.svg
new file mode 100644
index 00000000..95cbc63d
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/assets/play-icon.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/frontend-tools/chapters-editor/client/src/assets/play-tooltip-icon.svg b/frontend-tools/chapters-editor/client/src/assets/play-tooltip-icon.svg
new file mode 100644
index 00000000..5549690c
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/assets/play-tooltip-icon.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/frontend-tools/chapters-editor/client/src/assets/segment-end-new-cutaway.svg b/frontend-tools/chapters-editor/client/src/assets/segment-end-new-cutaway.svg
new file mode 100644
index 00000000..3b602d88
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/assets/segment-end-new-cutaway.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend-tools/chapters-editor/client/src/assets/segment-end-new.svg b/frontend-tools/chapters-editor/client/src/assets/segment-end-new.svg
new file mode 100644
index 00000000..3b602d88
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/assets/segment-end-new.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend-tools/chapters-editor/client/src/assets/segment-end.svg b/frontend-tools/chapters-editor/client/src/assets/segment-end.svg
new file mode 100644
index 00000000..3c74943c
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/assets/segment-end.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend-tools/chapters-editor/client/src/assets/segment-start-new-cutaway.svg b/frontend-tools/chapters-editor/client/src/assets/segment-start-new-cutaway.svg
new file mode 100644
index 00000000..20dc2a5c
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/assets/segment-start-new-cutaway.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend-tools/chapters-editor/client/src/assets/segment-start-new.svg b/frontend-tools/chapters-editor/client/src/assets/segment-start-new.svg
new file mode 100644
index 00000000..2b7d9751
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/assets/segment-start-new.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend-tools/chapters-editor/client/src/assets/segment-start.svg b/frontend-tools/chapters-editor/client/src/assets/segment-start.svg
new file mode 100644
index 00000000..f2bd3ccf
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/assets/segment-start.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend-tools/chapters-editor/client/src/components/ClipSegments.tsx b/frontend-tools/chapters-editor/client/src/components/ClipSegments.tsx
new file mode 100644
index 00000000..4c560642
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/components/ClipSegments.tsx
@@ -0,0 +1,93 @@
+import { formatTime, formatLongTime } from '@/lib/timeUtils';
+import '../styles/ClipSegments.css';
+
+export interface Segment {
+ id: number;
+ chapterTitle: string;
+ startTime: number;
+ endTime: number;
+}
+
+interface ClipSegmentsProps {
+ segments: Segment[];
+ selectedSegmentId?: number | null;
+}
+
+const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => {
+ // Sort segments by startTime
+ const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
+
+ // Handle delete segment click
+ const handleDeleteSegment = (segmentId: number) => {
+ // Create and dispatch the delete event
+ const deleteEvent = new CustomEvent('delete-segment', {
+ detail: { segmentId },
+ });
+ document.dispatchEvent(deleteEvent);
+ };
+
+ // Generate the same color background for a segment as shown in the timeline
+ const getSegmentColorClass = (index: number) => {
+ // Return CSS class based on index modulo 8
+ // This matches the CSS nth-child selectors in the timeline
+ return `segment-default-color segment-color-${(index % 8) + 1}`;
+ };
+
+ // Get selected segment
+ const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId);
+
+ return (
+
+
Chapters
+
+ {sortedSegments.map((segment, index) => (
+
+
+
+
+ {segment.chapterTitle ? (
+ {segment.chapterTitle}
+ ) : (
+ Chapter {index + 1}
+ )}
+
+
+ {formatTime(segment.startTime)} - {formatTime(segment.endTime)}
+
+
+ Duration: {formatLongTime(segment.endTime - segment.startTime)}
+
+
+
+
+
handleDeleteSegment(segment.id)}
+ >
+
+
+
+
+
+
+ ))}
+
+ {sortedSegments.length === 0 && (
+
+ No chapters created yet. Use the split button to create chapter segments.
+
+ )}
+
+ );
+};
+
+export default ClipSegments;
diff --git a/frontend-tools/chapters-editor/client/src/components/EditingTools.tsx b/frontend-tools/chapters-editor/client/src/components/EditingTools.tsx
new file mode 100644
index 00000000..f06ee319
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/components/EditingTools.tsx
@@ -0,0 +1,219 @@
+import '../styles/EditingTools.css';
+import { useEffect, useState } from 'react';
+import logger from '@/lib/logger';
+
+interface EditingToolsProps {
+ onSplit: () => void;
+ onReset: () => void;
+ onUndo: () => void;
+ onRedo: () => void;
+ onPlay: () => void;
+ canUndo: boolean;
+ canRedo: boolean;
+ isPlaying?: boolean;
+}
+
+const EditingTools = ({
+ onSplit,
+ onReset,
+ onUndo,
+ onRedo,
+ onPlay,
+ canUndo,
+ canRedo,
+ isPlaying = false,
+}: EditingToolsProps) => {
+ const [isSmallScreen, setIsSmallScreen] = useState(false);
+
+ useEffect(() => {
+ const checkScreenSize = () => {
+ setIsSmallScreen(window.innerWidth <= 640);
+ };
+
+ checkScreenSize();
+ window.addEventListener('resize', checkScreenSize);
+ return () => window.removeEventListener('resize', checkScreenSize);
+ }, []);
+
+ // Handle play button click with iOS fix
+ const handlePlay = () => {
+ // Ensure lastSeekedPosition is used when play is clicked
+ if (typeof window !== 'undefined') {
+ logger.debug('Play button clicked, current lastSeekedPosition:', window.lastSeekedPosition);
+ }
+
+ // Call the original handler
+ onPlay();
+ };
+
+ return (
+
+
+ {/* Left side - Play buttons group */}
+
+
+ {/* Play Preview button */}
+ {/*
+ {isPreviewMode ? (
+ <>
+
+
+
+
+
+ Stop Preview
+ Stop
+ >
+ ) : (
+ <>
+
+
+
+
+ Play Preview
+ Preview
+ >
+ )}
+ */}
+
+ {/* Standard Play button */}
+
+ {isPlaying ? (
+ <>
+
+
+
+
+
+ Pause
+ Pause
+ >
+ ) : (
+ <>
+
+
+
+
+ Play
+ Play
+ >
+ )}
+
+
+ {/* Segments Playback message (replaces play button during segments playback) */}
+ {/* {isPlayingSegments && !isSmallScreen && (
+
+
+
+
+
+
+ Preview Mode
+
+ )} */}
+
+ {/* Preview mode message (replaces play button) */}
+ {/* {isPreviewMode && (
+
+
+
+
+
+
+ Preview Mode
+
+ )} */}
+
+
+ {/* Right side - Editing tools */}
+
+
+
+
+
+
+ Undo
+
+
+
+
+
+
+ Redo
+
+
+
+
+
+
+ Reset
+
+
+
+
+ );
+};
+
+export default EditingTools;
diff --git a/frontend-tools/chapters-editor/client/src/components/IOSPlayPrompt.tsx b/frontend-tools/chapters-editor/client/src/components/IOSPlayPrompt.tsx
new file mode 100644
index 00000000..48dc6ac9
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/components/IOSPlayPrompt.tsx
@@ -0,0 +1,60 @@
+import React, { useState, useEffect } from 'react';
+import '../styles/IOSPlayPrompt.css';
+
+interface MobilePlayPromptProps {
+ videoRef: React.RefObject;
+ onPlay: () => void;
+}
+
+const MobilePlayPrompt: React.FC = ({ videoRef, onPlay }) => {
+ const [isVisible, setIsVisible] = useState(false);
+
+ // Check if the device is mobile or Safari browser
+ useEffect(() => {
+ const checkIsMobile = () => {
+ // More comprehensive check for mobile/tablet devices
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
+ navigator.userAgent
+ );
+ };
+
+ // Only show for mobile devices
+ const isMobile = checkIsMobile();
+ setIsVisible(isMobile);
+ }, []);
+
+ // Close the prompt when video plays
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ const handlePlay = () => {
+ // Just close the prompt when video plays
+ setIsVisible(false);
+ };
+
+ video.addEventListener('play', handlePlay);
+ return () => {
+ video.removeEventListener('play', handlePlay);
+ };
+ }, [videoRef]);
+
+ const handlePlayClick = () => {
+ onPlay();
+ // Prompt will be closed by the play event handler
+ };
+
+ if (!isVisible) return null;
+
+ return (
+
+
+
+ Click to start editing...
+
+
+
+ );
+};
+
+export default MobilePlayPrompt;
diff --git a/frontend-tools/chapters-editor/client/src/components/IOSVideoPlayer.tsx b/frontend-tools/chapters-editor/client/src/components/IOSVideoPlayer.tsx
new file mode 100644
index 00000000..e5bd3f48
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/components/IOSVideoPlayer.tsx
@@ -0,0 +1,197 @@
+import { useEffect, useState, useRef } from 'react';
+import { formatTime } from '@/lib/timeUtils';
+import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
+import '../styles/IOSVideoPlayer.css';
+
+interface IOSVideoPlayerProps {
+ videoRef: React.RefObject;
+ currentTime: number;
+ duration: number;
+}
+
+const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
+ const [videoUrl, setVideoUrl] = useState('');
+ const [iosVideoRef, setIosVideoRef] = useState(null);
+ const [posterImage, setPosterImage] = useState(undefined);
+
+ // Refs for hold-to-continue functionality
+ const incrementIntervalRef = useRef(null);
+ const decrementIntervalRef = useRef(null);
+
+ // Clean up intervals on unmount
+ useEffect(() => {
+ return () => {
+ if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
+ if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
+ };
+ }, []);
+
+ // Get the video source URL from the main player
+ useEffect(() => {
+ let url = '';
+ if (videoRef.current && videoRef.current.querySelector('source')) {
+ const source = videoRef.current.querySelector('source') as HTMLSourceElement;
+ if (source && source.src) {
+ url = source.src;
+ }
+ } else {
+ // Fallback to sample video if needed
+ url = '/videos/sample-video.mp4';
+ }
+ setVideoUrl(url);
+
+ // Check if the media is an audio file and set poster image
+ const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
+
+ // Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
+ const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
+ const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
+ setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
+ }, [videoRef]);
+
+ // Function to jump 15 seconds backward
+ const jumpBackward15 = () => {
+ if (iosVideoRef) {
+ const newTime = Math.max(0, iosVideoRef.currentTime - 15);
+ iosVideoRef.currentTime = newTime;
+ }
+ };
+
+ // Function to jump 15 seconds forward
+ const jumpForward15 = () => {
+ if (iosVideoRef) {
+ const newTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 15);
+ iosVideoRef.currentTime = newTime;
+ }
+ };
+
+ // Start continuous 50ms increment when button is held
+ const startIncrement = (e: React.MouseEvent | React.TouchEvent) => {
+ // Prevent default to avoid text selection
+ e.preventDefault();
+
+ if (!iosVideoRef) return;
+ if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
+
+ // First immediate adjustment
+ iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
+
+ // Setup continuous adjustment
+ incrementIntervalRef.current = setInterval(() => {
+ if (iosVideoRef) {
+ iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
+ }
+ }, 100);
+ };
+
+ // Stop continuous increment
+ const stopIncrement = () => {
+ if (incrementIntervalRef.current) {
+ clearInterval(incrementIntervalRef.current);
+ incrementIntervalRef.current = null;
+ }
+ };
+
+ // Start continuous 50ms decrement when button is held
+ const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
+ // Prevent default to avoid text selection
+ e.preventDefault();
+
+ if (!iosVideoRef) return;
+ if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
+
+ // First immediate adjustment
+ iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
+
+ // Setup continuous adjustment
+ decrementIntervalRef.current = setInterval(() => {
+ if (iosVideoRef) {
+ iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
+ }
+ }, 100);
+ };
+
+ // Stop continuous decrement
+ const stopDecrement = () => {
+ if (decrementIntervalRef.current) {
+ clearInterval(decrementIntervalRef.current);
+ decrementIntervalRef.current = null;
+ }
+ };
+
+ return (
+
+ {/* Current Time / Duration Display */}
+
+
+ {formatTime(currentTime)} / {formatTime(duration)}
+
+
+
+ {/* iOS-optimized Video Element with Native Controls */}
+
setIosVideoRef(ref)}
+ className="w-full rounded-md"
+ src={videoUrl}
+ controls
+ playsInline
+ webkit-playsinline="true"
+ x-webkit-airplay="allow"
+ preload="auto"
+ crossOrigin="anonymous"
+ poster={posterImage}
+ >
+
+ Your browser doesn't support HTML5 video.
+
+
+ {/* iOS Video Skip Controls */}
+
+
+ -15s
+
+
+ +15s
+
+
+
+ {/* iOS Fine Control Buttons */}
+
+
+ -50ms
+
+
+ +50ms
+
+
+
+
+
This player uses native iOS controls for better compatibility with iOS devices.
+
+
+ );
+};
+
+export default IOSVideoPlayer;
diff --git a/frontend-tools/chapters-editor/client/src/components/Modal.tsx b/frontend-tools/chapters-editor/client/src/components/Modal.tsx
new file mode 100644
index 00000000..ae7e2aa1
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/components/Modal.tsx
@@ -0,0 +1,74 @@
+import React, { useEffect } from 'react';
+import '../styles/Modal.css';
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title: string;
+ children: React.ReactNode;
+ actions?: React.ReactNode;
+}
+
+const Modal: React.FC = ({ isOpen, onClose, title, children, actions }) => {
+ // Close modal when Escape key is pressed
+ useEffect(() => {
+ const handleEscapeKey = (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && isOpen) {
+ onClose();
+ }
+ };
+
+ document.addEventListener('keydown', handleEscapeKey);
+
+ // Disable body scrolling when modal is open
+ if (isOpen) {
+ document.body.style.overflow = 'hidden';
+ }
+
+ return () => {
+ document.removeEventListener('keydown', handleEscapeKey);
+ document.body.style.overflow = '';
+ };
+ }, [isOpen, onClose]);
+
+ if (!isOpen) return null;
+
+ // Handle click outside the modal content to close it
+ const handleClickOutside = (event: React.MouseEvent) => {
+ if (event.target === event.currentTarget) {
+ onClose();
+ }
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
{title}
+
+
+
+
+
+
+
+
+
{children}
+
+ {actions &&
{actions}
}
+
+
+ );
+};
+
+export default Modal;
diff --git a/frontend-tools/chapters-editor/client/src/components/TimelineControls.tsx b/frontend-tools/chapters-editor/client/src/components/TimelineControls.tsx
new file mode 100644
index 00000000..f7e79066
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/components/TimelineControls.tsx
@@ -0,0 +1,4089 @@
+import { useRef, useEffect, useState, useCallback } from 'react';
+import { formatTime, formatDetailedTime } from '../lib/timeUtils';
+import { generateSolidColor } from '../lib/videoUtils';
+import { Segment } from './ClipSegments';
+import Modal from './Modal';
+import { autoSaveVideo } from '../services/videoApi';
+import logger from '../lib/logger';
+import '../styles/TimelineControls.css';
+import '../styles/TwoRowTooltip.css';
+import playIcon from '../assets/play-icon.svg';
+import pauseIcon from '../assets/pause-icon.svg';
+import playFromBeginningIcon from '../assets/play-from-beginning-icon.svg';
+import segmentEndIcon from '../assets/segment-end-new.svg';
+import segmentStartIcon from '../assets/segment-start-new.svg';
+import segmentNewStartIcon from '../assets/segment-start-new-cutaway.svg';
+import segmentNewEndIcon from '../assets/segment-end-new-cutaway.svg';
+
+// Add styles for the media page link
+const mediaPageLinkStyles = {
+ color: '#007bff',
+ textDecoration: 'none',
+ fontWeight: 'bold',
+ '&:hover': {
+ textDecoration: 'underline',
+ color: '#0056b3',
+ },
+} as const;
+
+interface TimelineControlsProps {
+ currentTime: number;
+ duration: number;
+ thumbnails: string[];
+ trimStart: number;
+ trimEnd: number;
+ splitPoints: number[];
+ zoomLevel: number;
+ clipSegments: Segment[];
+ selectedSegmentId?: number | null;
+ onSelectedSegmentChange?: (segmentId: number | null) => void;
+ onSegmentUpdate?: (segmentId: number, updates: Partial) => void;
+ onChapterSave?: (chapters: { chapterTitle: string; from: string; to: string }[]) => void;
+ onTrimStartChange: (time: number) => void;
+ onTrimEndChange: (time: number) => void;
+ onZoomChange: (level: number) => void;
+ onSeek: (time: number) => void;
+ videoRef: React.RefObject;
+ hasUnsavedChanges?: boolean;
+ isIOSUninitialized?: boolean;
+ isPlaying: boolean;
+ setIsPlaying: (playing: boolean) => void;
+ onPlayPause: () => void;
+ isPlayingSegments?: boolean;
+}
+
+// Function to calculate and constrain tooltip position to keep it on screen
+// Uses smooth transitions instead of hard breakpoints to eliminate jumping
+const constrainTooltipPosition = (positionPercent: number) => {
+ // Smooth transition zones instead of hard breakpoints
+ const leftTransitionStart = 0;
+ const leftTransitionEnd = 15;
+ const rightTransitionStart = 75;
+ const rightTransitionEnd = 100;
+
+ let leftValue: string;
+ let transform: string;
+
+ if (positionPercent <= leftTransitionEnd) {
+ // Left side: smooth transition from center to left-aligned
+ if (positionPercent <= leftTransitionStart) {
+ // Fully left-aligned
+ leftValue = '0%';
+ transform = 'none';
+ } else {
+ // Smooth transition zone
+ const transitionProgress =
+ (positionPercent - leftTransitionStart) / (leftTransitionEnd - leftTransitionStart);
+ const translateAmount = -50 * transitionProgress; // Gradually reduce from 0% to -50%
+ leftValue = `${positionPercent}%`;
+ transform = `translateX(${translateAmount}%)`;
+ }
+ } else if (positionPercent >= rightTransitionStart) {
+ // Right side: smooth transition from center to right-aligned
+ if (positionPercent >= rightTransitionEnd) {
+ // Fully right-aligned
+ leftValue = '100%';
+ transform = 'translateX(-100%)';
+ } else {
+ // Smooth transition zone
+ const transitionProgress =
+ (positionPercent - rightTransitionStart) / (rightTransitionEnd - rightTransitionStart);
+ const translateAmount = -50 - 50 * transitionProgress; // Gradually change from -50% to -100%
+ leftValue = `${positionPercent}%`;
+ transform = `translateX(${translateAmount}%)`;
+ }
+ } else {
+ // Center zone: normal centered positioning
+ leftValue = `${positionPercent}%`;
+ transform = 'translateX(-50%)';
+ }
+
+ return { left: leftValue, transform };
+};
+
+const TimelineControls = ({
+ currentTime,
+ duration,
+ trimStart,
+ trimEnd,
+ splitPoints,
+ zoomLevel,
+ clipSegments,
+ selectedSegmentId: externalSelectedSegmentId,
+ onSelectedSegmentChange,
+ onSegmentUpdate,
+ onChapterSave,
+ onTrimStartChange,
+ onTrimEndChange,
+ onZoomChange,
+ onSeek,
+ videoRef,
+ hasUnsavedChanges = false,
+ isIOSUninitialized = false,
+ isPlaying,
+ setIsPlaying,
+ onPlayPause, // Add this prop
+ isPlayingSegments = false,
+}: TimelineControlsProps) => {
+ // Helper function to generate proper chapter name based on chronological position
+ const generateChapterName = (newSegmentStartTime: number, existingSegments: Segment[]): string => {
+ // Create a temporary array with all segments including the new one
+ const allSegments = [...existingSegments, { startTime: newSegmentStartTime } as Segment];
+ // Sort by start time to find chronological position
+ const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
+ // Find the index of our new segment
+ const chapterIndex = sortedSegments.findIndex(seg => seg.startTime === newSegmentStartTime);
+ return `Chapter ${chapterIndex + 1}`;
+ };
+
+ const timelineRef = useRef(null);
+ const leftHandleRef = useRef(null);
+ const rightHandleRef = useRef(null);
+ // Use external selectedSegmentId if provided, otherwise use internal state
+ const [internalSelectedSegmentId, setInternalSelectedSegmentId] = useState(null);
+ const selectedSegmentId =
+ externalSelectedSegmentId !== undefined ? externalSelectedSegmentId : internalSelectedSegmentId;
+ const setSelectedSegmentId = (segmentId: number | null) => {
+ if (onSelectedSegmentChange) {
+ onSelectedSegmentChange(segmentId);
+ } else {
+ setInternalSelectedSegmentId(segmentId);
+ }
+ };
+ const [showEmptySpaceTooltip, setShowEmptySpaceTooltip] = useState(false);
+ const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
+ const [clickedTime, setClickedTime] = useState(0);
+ const [isZoomDropdownOpen, setIsZoomDropdownOpen] = useState(false);
+ const [availableSegmentDuration, setAvailableSegmentDuration] = useState(30); // Default 30 seconds
+ const [isPlayingSegment, setIsPlayingSegment] = useState(false);
+ const [activeSegment, setActiveSegment] = useState(null);
+ const [displayTime, setDisplayTime] = useState(0);
+ // Track when we should continue playing (clicking play after boundary stop)
+ const [continuePastBoundary, setContinuePastBoundary] = useState(false);
+
+ // Reference for the scrollable container
+ const scrollContainerRef = useRef(null);
+
+ // Chapter editor state
+ const [editingChapterTitle, setEditingChapterTitle] = useState('');
+ const [chapterHasUnsavedChanges, setChapterHasUnsavedChanges] = useState(false);
+
+ // Sort segments by startTime for chapter editor
+ const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
+ const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId);
+
+ // Auto-save related state
+ const [lastAutoSaveTime, setLastAutoSaveTime] = useState('');
+ const [isAutoSaving, setIsAutoSaving] = useState(false);
+ const autoSaveTimerRef = useRef(null);
+ const clipSegmentsRef = useRef(clipSegments);
+
+
+ // Keep clipSegmentsRef updated
+ useEffect(() => {
+ clipSegmentsRef.current = clipSegments;
+ }, [clipSegments]);
+
+
+ // Auto-save function
+ const performAutoSave = useCallback(async () => {
+ try {
+ setIsAutoSaving(true);
+
+ // Format segments data for API request - use ref to get latest segments and sort by start time
+ const chapters = clipSegmentsRef.current
+ .sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
+ .map((chapter) => ({
+ startTime: formatDetailedTime(chapter.startTime),
+ endTime: formatDetailedTime(chapter.endTime),
+ chapterTitle: chapter.chapterTitle,
+ }));
+
+ logger.debug('chapters', chapters);
+
+ const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
+ // For testing, use '1234' if no mediaId is available
+ const finalMediaId = mediaId || '1234';
+
+ logger.debug('mediaId', finalMediaId);
+
+ if (!finalMediaId || chapters.length === 0) {
+ logger.debug('No mediaId or segments, skipping auto-save');
+ setIsAutoSaving(false);
+ return;
+ }
+
+ logger.debug('Auto-saving segments:', { mediaId: finalMediaId, chapters });
+
+ const response = await autoSaveVideo(finalMediaId, { chapters });
+
+ if (response.success === true) {
+ logger.debug('Auto-save successful');
+ // Format the timestamp for display
+ const date = new Date(response.updated_at || new Date().toISOString());
+ const formattedTime = date
+ .toLocaleString('en-US', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+ })
+ .replace(',', '');
+
+ setLastAutoSaveTime(formattedTime);
+ logger.debug('Auto-save successful:', formattedTime);
+ } else {
+ logger.error('Auto-save failed: (TimelineControls.tsx)');
+ }
+ } catch (error) {
+ logger.error('Auto-save error: (TimelineControls.tsx)', error);
+ } finally {
+ setIsAutoSaving(false);
+ }
+ }, []);
+
+ // Schedule auto-save with debounce
+ const scheduleAutoSave = useCallback(() => {
+ // Clear any existing timer
+ if (autoSaveTimerRef.current) {
+ clearTimeout(autoSaveTimerRef.current);
+ logger.debug('Cleared existing auto-save timer');
+ }
+
+ logger.debug('Scheduling new auto-save in 1 second...');
+
+ // Schedule new auto-save after 1 second of inactivity
+ const timerId = setTimeout(() => {
+ logger.debug('Auto-save timer fired! Calling performAutoSave...');
+ performAutoSave();
+ }, 1000);
+
+ autoSaveTimerRef.current = timerId;
+ logger.debug('Timer ID set:', timerId);
+ }, [performAutoSave]);
+
+ // Update editing title when selected segment changes
+ useEffect(() => {
+ if (selectedSegment) {
+ // Check if the chapter title is a default generated name (e.g., "Chapter 1", "Chapter 2", etc.)
+ const isDefaultChapterName = selectedSegment.chapterTitle &&
+ /^Chapter \d+$/.test(selectedSegment.chapterTitle);
+
+ // If it's a default name, show empty string so placeholder appears
+ // If it's a custom title, show the actual title
+ setEditingChapterTitle(isDefaultChapterName ? '' : (selectedSegment.chapterTitle || ''));
+ } else {
+ setEditingChapterTitle('');
+ }
+ }, [selectedSegmentId, selectedSegment]);
+
+ // Handle chapter title change
+ const handleChapterTitleChange = (value: string) => {
+ setEditingChapterTitle(value);
+ setChapterHasUnsavedChanges(true);
+
+ // Update the segment immediately
+ if (selectedSegmentId && onSegmentUpdate) {
+ onSegmentUpdate(selectedSegmentId, { chapterTitle: value });
+ }
+ };
+
+ // Handle save chapters
+ /* const handleSaveChapters = () => {
+ if (!onChapterSave) return;
+
+ // Convert segments to chapter format
+ const chapters = sortedSegments.map((segment, index) => ({
+ name: segment.chapterTitle || `Chapter ${index + 1}`,
+ from: formatDetailedTime(segment.startTime),
+ to: formatDetailedTime(segment.endTime),
+ }));
+
+ onChapterSave(chapters);
+ setChapterHasUnsavedChanges(false);
+ }; */
+
+ // Helper function for time adjustment buttons to maintain playback state
+ /* const handleTimeAdjustment = (offsetSeconds: number) => (e: React.MouseEvent) => {
+ e.stopPropagation();
+
+ // Calculate new time based on offset (positive or negative)
+ const newTime =
+ offsetSeconds < 0
+ ? Math.max(0, clickedTime + offsetSeconds) // For negative offsets (going back)
+ : Math.min(duration, clickedTime + offsetSeconds); // For positive offsets (going forward)
+
+ // Save the current playing state before seeking
+ const wasPlaying = isPlayingSegment;
+
+ // Seek to the new time
+ onSeek(newTime);
+
+ // Update both clicked time and display time
+ setClickedTime(newTime);
+ setDisplayTime(newTime);
+
+ // Resume playback if it was playing before
+ if (wasPlaying && videoRef.current) {
+ videoRef.current.play();
+ setIsPlayingSegment(true);
+ }
+ }; */
+
+ // Enhanced helper for continuous time adjustment when button is held down
+ const handleContinuousTimeAdjustment = (offsetSeconds: number) => {
+ // Fixed adjustment amount - exactly 50ms each time
+ const adjustmentValue = offsetSeconds;
+ // Hold timer for continuous adjustment
+ let holdTimer: NodeJS.Timeout | null = null;
+ let continuousTimer: NodeJS.Timeout | null = null;
+ // Store the last time value to correctly calculate the next increment
+ let lastTimeValue = clickedTime;
+
+ // Function to perform time adjustment
+ const adjustTime = () => {
+ // Calculate new time based on fixed offset (positive or negative)
+ const newTime =
+ adjustmentValue < 0
+ ? Math.max(0, lastTimeValue + adjustmentValue) // For negative offsets (going back)
+ : Math.min(duration, lastTimeValue + adjustmentValue); // For positive offsets (going forward)
+
+ // Update our last time value for next adjustment
+ lastTimeValue = newTime;
+
+ // Save the current playing state before seeking
+ const wasPlaying = isPlayingSegment;
+
+ // Seek to the new time
+ onSeek(newTime);
+
+ // Update both clicked time and display time
+ setClickedTime(newTime);
+ setDisplayTime(newTime);
+
+ // Update tooltip position
+ if (timelineRef.current) {
+ const rect = timelineRef.current.getBoundingClientRect();
+ const positionPercent = (newTime / duration) * 100;
+ const xPos = rect.left + rect.width * (positionPercent / 100);
+ setTooltipPosition({
+ x: xPos,
+ y: rect.top - 10,
+ });
+
+ // Find if we're in a segment at the new time
+ const segmentAtTime = clipSegments.find((seg) => newTime >= seg.startTime && newTime <= seg.endTime);
+
+ if (segmentAtTime) {
+ // Show segment tooltip
+ setSelectedSegmentId(segmentAtTime.id);
+ setShowEmptySpaceTooltip(false);
+ } else {
+ // Show cutaway tooltip
+ setSelectedSegmentId(null);
+ const availableSpace = calculateAvailableSpace(newTime);
+ setAvailableSegmentDuration(availableSpace);
+ setShowEmptySpaceTooltip(true);
+ }
+ }
+
+ // Resume playback if it was playing before
+ if (wasPlaying && videoRef.current) {
+ videoRef.current.play();
+ setIsPlayingSegment(true);
+ }
+ };
+
+ // Return mouse event handlers with touch support
+ return {
+ onMouseDown: (e: React.MouseEvent) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ // Update the initial last time value
+ lastTimeValue = clickedTime;
+
+ // Perform initial adjustment
+ adjustTime();
+
+ // Start continuous adjustment after 1.5s hold
+ holdTimer = setTimeout(() => {
+ // After 1.5s delay, start adjusting at a slower pace (every 200ms)
+ continuousTimer = setInterval(adjustTime, 200);
+ }, 750);
+
+ // Add mouse up and leave handlers to document to ensure we catch the release
+ const clearTimers = () => {
+ if (holdTimer) {
+ clearTimeout(holdTimer);
+ holdTimer = null;
+ }
+ if (continuousTimer) {
+ clearInterval(continuousTimer);
+ continuousTimer = null;
+ }
+ document.removeEventListener('mouseup', clearTimers);
+ document.removeEventListener('mouseleave', clearTimers);
+ };
+
+ document.addEventListener('mouseup', clearTimers);
+ document.addEventListener('mouseleave', clearTimers);
+ },
+ onTouchStart: (e: React.TouchEvent) => {
+ e.stopPropagation();
+ e.preventDefault();
+ 21;
+
+ // Update the initial last time value
+ lastTimeValue = clickedTime;
+
+ // Perform initial adjustment
+ adjustTime();
+
+ // Start continuous adjustment after 1.5s hold
+ holdTimer = setTimeout(() => {
+ // After 1.5s delay, start adjusting at a slower pace (every 200ms)
+ continuousTimer = setInterval(adjustTime, 200);
+ }, 750);
+
+ // Add touch end handler to ensure we catch the release
+ const clearTimers = () => {
+ if (holdTimer) {
+ clearTimeout(holdTimer);
+ holdTimer = null;
+ }
+ if (continuousTimer) {
+ clearInterval(continuousTimer);
+ continuousTimer = null;
+ }
+ document.removeEventListener('touchend', clearTimers);
+ document.removeEventListener('touchcancel', clearTimers);
+ };
+
+ document.addEventListener('touchend', clearTimers);
+ document.addEventListener('touchcancel', clearTimers);
+ },
+ onClick: (e: React.MouseEvent) => {
+ // This prevents the click event from firing twice
+ e.stopPropagation();
+ },
+ };
+ };
+
+ // Modal states
+ const [showSaveChaptersModal, setShowSaveChaptersModal] = useState(false);
+ const [showProcessingModal, setShowProcessingModal] = useState(false);
+ const [showSuccessModal, setShowSuccessModal] = useState(false);
+ const [showErrorModal, setShowErrorModal] = useState(false);
+ const [successMessage, setSuccessMessage] = useState('');
+ const [errorMessage, setErrorMessage] = useState('');
+ const [redirectUrl, setRedirectUrl] = useState('');
+ const [saveType, setSaveType] = useState<'chapters'>('chapters');
+
+ // Calculate positions as percentages
+ const currentTimePercent = duration > 0 ? (currentTime / duration) * 100 : 0;
+ const trimStartPercent = duration > 0 ? (trimStart / duration) * 100 : 0;
+ const trimEndPercent = duration > 0 ? (trimEnd / duration) * 100 : 0;
+
+ // No need for an extra effect here as we handle displayTime updates in the segment playback effect
+
+ // Save Chapters handler
+ const handleSaveChaptersConfirm = async () => {
+ // Close confirmation modal and show processing modal
+ setShowSaveChaptersModal(false);
+ setShowProcessingModal(true);
+ setSaveType('chapters');
+
+ try {
+ // Format chapters data for API request - sort by start time first
+ const chapters = clipSegments
+ .filter((segment) => segment.chapterTitle && segment.chapterTitle.trim())
+ .sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
+ .map((segment) => ({
+ chapterTitle: segment.chapterTitle || `Chapter ${segment.id}`,
+ from: formatDetailedTime(segment.startTime),
+ to: formatDetailedTime(segment.endTime),
+ }));
+
+ // Allow saving even when no chapters exist (will send empty array)
+ // Call the onChapterSave function if provided
+ if (onChapterSave) {
+ await onChapterSave(chapters);
+ setShowProcessingModal(false);
+
+ if (chapters.length === 0) {
+ setSuccessMessage('All chapters cleared successfully!');
+ } else {
+ setSuccessMessage('Chapters saved successfully!');
+ }
+
+ // Set redirect URL to media page
+ const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
+ if (mediaId) {
+ setRedirectUrl(`/view?m=${mediaId}`);
+ }
+
+ setShowSuccessModal(true);
+ } else {
+ setErrorMessage('Chapter save function not available');
+ setShowErrorModal(true);
+ setShowProcessingModal(false);
+ }
+ } catch (error) {
+ logger.error('Error saving chapters:', error);
+ setShowProcessingModal(false);
+
+ // Set error message and show error modal
+ const errorMsg = error instanceof Error ? error.message : 'An error occurred while saving chapters';
+ logger.debug('Save chapters error (exception):', errorMsg);
+ setErrorMessage(errorMsg);
+ setShowErrorModal(true);
+ }
+ };
+
+ // Auto-scroll and update tooltip position when seeking to a different time
+ useEffect(() => {
+ if (scrollContainerRef.current && timelineRef.current && zoomLevel > 1) {
+ const containerWidth = scrollContainerRef.current.clientWidth;
+ const timelineWidth = timelineRef.current.clientWidth;
+ const markerPosition = (currentTime / duration) * timelineWidth;
+
+ // Calculate the position where we want the marker to be visible
+ // (center of the viewport when possible)
+ const desiredScrollPosition = Math.max(0, markerPosition - containerWidth / 2);
+
+ // Smooth scroll to the desired position
+ scrollContainerRef.current.scrollTo({
+ left: desiredScrollPosition,
+ behavior: 'smooth',
+ });
+
+ // Update tooltip position to stay with the marker
+ const rect = timelineRef.current.getBoundingClientRect();
+
+ // Calculate the visible position of the marker after scrolling
+ const containerRect = scrollContainerRef.current.getBoundingClientRect();
+ const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft;
+ const markerX = visibleTimelineLeft + (currentTimePercent / 100) * rect.width;
+
+ // Only update if we have a tooltip showing
+ if (selectedSegmentId !== null || showEmptySpaceTooltip) {
+ setTooltipPosition({
+ x: markerX,
+ y: rect.top - 10,
+ });
+ setClickedTime(currentTime);
+ }
+ }
+ }, [currentTime, zoomLevel, duration, selectedSegmentId, showEmptySpaceTooltip, currentTimePercent]);
+
+ // Effect to check active segment boundaries during playback - DISABLED for continuous playback
+ useEffect(() => {
+ // Boundary checking disabled - allow continuous playback through all segments
+ logger.debug('Segment boundary checking disabled - continuous playback enabled');
+ return;
+ }, [activeSegment, isPlayingSegment, continuePastBoundary, clipSegments]);
+
+ // Update display time and check for transitions between segments and empty spaces
+ useEffect(() => {
+ // Always update display time to match current video time when playing
+ if (videoRef.current) {
+ // If video is playing, always update the displayed time in the tooltip
+ if (!videoRef.current.paused) {
+ setDisplayTime(currentTime);
+
+ // Also update clicked time to keep them in sync when playing
+ // This ensures correct time is shown when pausing
+ setClickedTime(currentTime);
+
+ if (selectedSegmentId !== null) {
+ setIsPlayingSegment(true);
+ }
+
+ // While playing, continuously check if we're in a segment or empty space
+ // to update the tooltip accordingly, regardless of where we started playing
+
+ // Check if we're in any segment at current time
+ const segmentAtCurrentTime = clipSegments.find(
+ (seg) => currentTime >= seg.startTime && currentTime <= seg.endTime
+ );
+
+ // Update tooltip position based on current time percentage
+ const newTimePercent = (currentTime / duration) * 100;
+ if (timelineRef.current) {
+ const timelineWidth = timelineRef.current.offsetWidth;
+ const markerX = (newTimePercent / 100) * timelineWidth;
+ setTooltipPosition({
+ x: markerX,
+ y: timelineRef.current.getBoundingClientRect().top - 10,
+ });
+ }
+
+ // Check for the special "continue past segment" state in sessionStorage
+ const isContinuingPastSegment = sessionStorage.getItem('continuingPastSegment') === 'true';
+
+ // If we're in a segment now
+ if (segmentAtCurrentTime) {
+ // Get video element reference for boundary checks
+ const video = videoRef.current;
+
+ // Special check for virtual segments (cutaway playback)
+ // If we have an active virtual segment (negative ID) and we're in a regular segment now,
+ // we need to STOP at the start of this segment - that's the boundary of our cutaway
+ const isPlayingVirtualSegment = activeSegment && activeSegment.id < 0 && isPlayingSegment;
+
+ // If the active segment is different from the current segment and it's not a virtual segment
+ // and we're not in "continue past boundary" mode, set this segment as the active segment
+ if (
+ activeSegment?.id !== segmentAtCurrentTime.id &&
+ !isPlayingVirtualSegment &&
+ !isContinuingPastSegment &&
+ !continuePastBoundary
+ ) {
+ // We've entered a new segment during normal playback
+ logger.debug(
+ `Entered a new segment during playback: ${segmentAtCurrentTime.id}, setting as active`
+ );
+ setActiveSegment(segmentAtCurrentTime);
+ setSelectedSegmentId(segmentAtCurrentTime.id);
+ setShowEmptySpaceTooltip(false);
+ // Reset continuation flags to ensure boundary detection works for this new segment
+ setContinuePastBoundary(false);
+ sessionStorage.removeItem('continuingPastSegment');
+ }
+
+ // If we're playing a virtual segment and enter a real segment, we've reached our boundary
+ // We should stop playback
+ if (isPlayingVirtualSegment && video && segmentAtCurrentTime) {
+ logger.debug(
+ `CUTAWAY BOUNDARY REACHED: Current position ${formatDetailedTime(
+ video.currentTime
+ )} at segment ${segmentAtCurrentTime.id} - STOPPING at boundary ${formatDetailedTime(
+ segmentAtCurrentTime.startTime
+ )}`
+ );
+ video.pause();
+ // Force exact time position with high precision and multiple attempts
+ setTimeout(() => {
+ if (videoRef.current) {
+ // First seek directly to exact start time, no offset
+ videoRef.current.currentTime = segmentAtCurrentTime.startTime;
+ // Update UI immediately to match video position
+ onSeek(segmentAtCurrentTime.startTime);
+ // Also update tooltip time displays
+ setDisplayTime(segmentAtCurrentTime.startTime);
+ setClickedTime(segmentAtCurrentTime.startTime);
+
+ // Reset continuePastBoundary when reaching a segment boundary
+ setContinuePastBoundary(false);
+
+ // Update tooltip to show segment tooltip at boundary
+ setSelectedSegmentId(segmentAtCurrentTime.id);
+ setShowEmptySpaceTooltip(false);
+
+ // Force multiple adjustments to ensure exact precision
+ const verifyPosition = () => {
+ if (videoRef.current) {
+ // Always force the exact time in every verification
+ videoRef.current.currentTime = segmentAtCurrentTime.startTime;
+
+ // Make sure we update the UI to reflect the corrected position
+ onSeek(segmentAtCurrentTime.startTime);
+
+ // Update the displayTime and clickedTime state to match exact position
+ setDisplayTime(segmentAtCurrentTime.startTime);
+ setClickedTime(segmentAtCurrentTime.startTime);
+
+ logger.debug(
+ `Position corrected to exact segment boundary: ${formatDetailedTime(
+ videoRef.current.currentTime
+ )} (target: ${formatDetailedTime(segmentAtCurrentTime.startTime)})`
+ );
+ }
+ };
+
+ // Apply multiple correction attempts with increasing delays
+ setTimeout(verifyPosition, 10); // Immediate correction
+ setTimeout(verifyPosition, 20); // First correction
+ setTimeout(verifyPosition, 50); // Second correction
+ setTimeout(verifyPosition, 100); // Third correction
+ setTimeout(verifyPosition, 200); // Final correction
+
+ // Also add event listeners to ensure position is corrected whenever video state changes
+ videoRef.current.addEventListener('seeked', verifyPosition);
+ videoRef.current.addEventListener('canplay', verifyPosition);
+ videoRef.current.addEventListener('waiting', verifyPosition);
+
+ // Remove these event listeners after a short time
+ setTimeout(() => {
+ if (videoRef.current) {
+ videoRef.current.removeEventListener('seeked', verifyPosition);
+ videoRef.current.removeEventListener('canplay', verifyPosition);
+ videoRef.current.removeEventListener('waiting', verifyPosition);
+ }
+ }, 300);
+ }
+ }, 10);
+ setIsPlayingSegment(false);
+ setActiveSegment(null);
+ return; // Exit early, we've handled this case
+ }
+
+ // Only update active segment if we're not in "continue past segment" mode
+ // or if we're in a virtual cutaway segment
+ const continuingPastSegment =
+ (activeSegment === null && isPlayingSegment === true) ||
+ isContinuingPastSegment ||
+ isPlayingVirtualSegment;
+
+ if (continuingPastSegment) {
+ // We're in the special case where we're continuing past a segment boundary
+ // or playing a cutaway area
+ // Just update the tooltip, but don't reactivate boundary checking
+ if (selectedSegmentId !== segmentAtCurrentTime.id || showEmptySpaceTooltip) {
+ logger.debug(
+ 'Tooltip updated for segment during continued playback:',
+ segmentAtCurrentTime.id,
+ isPlayingVirtualSegment ? '(cutaway playback - keeping virtual segment)' : ''
+ );
+ setSelectedSegmentId(segmentAtCurrentTime.id);
+ setShowEmptySpaceTooltip(false);
+
+ // If we're in a different segment now, clear the continuation flag
+ // but only if it's not the same segment we were in before
+ // AND we're not playing a cutaway area
+ if (
+ !isPlayingVirtualSegment &&
+ sessionStorage.getItem('lastSegmentId') !== segmentAtCurrentTime.id.toString()
+ ) {
+ logger.debug('Moved to a different segment - ending continuation mode');
+ sessionStorage.removeItem('continuingPastSegment');
+ }
+ }
+ } else {
+ // Normal case - update both tooltip and active segment
+ if (activeSegment?.id !== segmentAtCurrentTime.id || showEmptySpaceTooltip) {
+ logger.debug('Playback moved into segment:', segmentAtCurrentTime.id);
+ setSelectedSegmentId(segmentAtCurrentTime.id);
+ setActiveSegment(segmentAtCurrentTime);
+ setShowEmptySpaceTooltip(false);
+
+ // Store the current segment ID for comparison later
+ sessionStorage.setItem('lastSegmentId', segmentAtCurrentTime.id.toString());
+ }
+ }
+ }
+ // If we're in empty space now
+ else {
+ // Check if we need to change the tooltip (we were in a segment before)
+ if (activeSegment !== null || !showEmptySpaceTooltip) {
+ logger.debug('Playback moved to empty space');
+ setSelectedSegmentId(null);
+ setActiveSegment(null);
+
+ // Calculate available space for new segment before showing tooltip
+ const availableSpace = calculateAvailableSpace(currentTime);
+ setAvailableSegmentDuration(availableSpace);
+
+ // Show empty space tooltip if there's enough space
+ if (availableSpace >= 0.5) {
+ setShowEmptySpaceTooltip(true);
+ logger.debug('Empty space with available duration:', availableSpace);
+ } else {
+ setShowEmptySpaceTooltip(false);
+ }
+ }
+ }
+ } else if (videoRef.current.paused && isPlayingSegment) {
+ // When just paused from playing state, update display time to show the actual stopped position
+ setDisplayTime(currentTime);
+ setClickedTime(currentTime);
+ setIsPlayingSegment(false);
+
+ // Log the stopping point
+ logger.debug('Video paused at:', formatDetailedTime(currentTime));
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [currentTime, isPlayingSegment, activeSegment, selectedSegmentId, clipSegments]);
+
+ // Close zoom dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ const target = event.target as HTMLElement;
+ if (isZoomDropdownOpen && !target.closest('.zoom-dropdown-container')) {
+ setIsZoomDropdownOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isZoomDropdownOpen]);
+
+ // Listen for segment updates and trigger auto-save
+ useEffect(() => {
+ const handleSegmentUpdate = (event: CustomEvent) => {
+ const { recordHistory, fromAutoSave } = event.detail;
+ logger.debug('handleSegmentUpdate called, recordHistory:', recordHistory, 'fromAutoSave:', fromAutoSave);
+ // Only auto-save when history is recorded and not loading from auto-save
+ if (recordHistory && !fromAutoSave) {
+ logger.debug('Calling scheduleAutoSave from handleSegmentUpdate');
+ scheduleAutoSave();
+ }
+ };
+
+ const handleSegmentDragEnd = () => {
+ // Trigger auto-save when drag operations end
+ scheduleAutoSave();
+ };
+
+ const handleTrimUpdate = (event: CustomEvent) => {
+ const { recordHistory } = event.detail;
+ // Only auto-save when history is recorded (i.e., after trim operations complete)
+ if (recordHistory) {
+ scheduleAutoSave();
+ }
+ };
+
+ document.addEventListener('update-segments', handleSegmentUpdate as EventListener);
+ document.addEventListener('segment-drag-end', handleSegmentDragEnd);
+ document.addEventListener('update-trim', handleTrimUpdate as EventListener);
+ document.addEventListener('delete-segment', scheduleAutoSave);
+ document.addEventListener('split-segment', scheduleAutoSave);
+ document.addEventListener('undo-redo-autosave', scheduleAutoSave);
+
+ return () => {
+ logger.debug('Cleaning up auto-save event listeners...');
+ document.removeEventListener('update-segments', handleSegmentUpdate as EventListener);
+ document.removeEventListener('segment-drag-end', handleSegmentDragEnd);
+ document.removeEventListener('update-trim', handleTrimUpdate as EventListener);
+ document.removeEventListener('delete-segment', scheduleAutoSave);
+ document.removeEventListener('split-segment', scheduleAutoSave);
+ document.removeEventListener('undo-redo-autosave', scheduleAutoSave);
+
+ // Clear any pending auto-save timer
+ if (autoSaveTimerRef.current) {
+ logger.debug('Clearing auto-save timer in cleanup:', autoSaveTimerRef.current);
+ clearTimeout(autoSaveTimerRef.current);
+ }
+ };
+ }, [scheduleAutoSave]);
+
+ // Perform initial auto-save when component mounts with segments
+ useEffect(() => {
+ if (clipSegments.length > 0 && !lastAutoSaveTime) {
+ // Perform initial auto-save after a short delay
+ setTimeout(() => {
+ performAutoSave();
+ }, 500);
+ }
+ }, [lastAutoSaveTime, performAutoSave]);
+
+ // Load saved segments from MEDIA_DATA on component mount
+ useEffect(() => {
+ const loadSavedSegments = () => {
+ // Get savedSegments directly from window.MEDIA_DATA
+ let savedData = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || null;
+
+ try {
+ if (savedData && savedData.chapters && savedData.chapters.length > 0) {
+ logger.debug('Found saved segments:', savedData);
+
+ // Convert the saved segments to the format expected by the component
+ const convertedSegments: Segment[] = savedData.chapters.map((seg: any , index: number) => ({
+ id: Date.now() + index, // Generate unique IDs
+ chapterTitle: seg.chapterTitle || `Chapter ${index + 1}`,
+ startTime: parseTimeString(seg.startTime),
+ endTime: parseTimeString(seg.endTime),
+ }));
+
+ // Dispatch event to update segments
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: convertedSegments,
+ recordHistory: false, // Don't record loading saved segments in history
+ fromAutoSave: true,
+ },
+ });
+ document.dispatchEvent(updateEvent);
+
+ // Update the last auto-save time
+ if (savedData.updated_at) {
+ const date = new Date(savedData.updated_at);
+ const formattedTime = date
+ .toLocaleString('en-US', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+ })
+ .replace(',', '');
+ setLastAutoSaveTime(formattedTime);
+ }
+ } else {
+ logger.debug('No saved segments found');
+ }
+ } catch (error) {
+ console.error('Error loading saved segments:', error);
+ }
+ };
+
+ // Helper function to parse time string "HH:MM:SS.mmm" to seconds
+ const parseTimeString = (timeStr: string): number => {
+ const parts = timeStr.split(':');
+ if (parts.length !== 3) return 0;
+
+ const hours = parseInt(parts[0]) || 0;
+ const minutes = parseInt(parts[1]) || 0;
+ const secondsParts = parts[2].split('.');
+ const seconds = parseInt(secondsParts[0]) || 0;
+ const milliseconds = parseInt(secondsParts[1]) || 0;
+
+ return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000;
+ };
+
+ // Load saved segments after a short delay to ensure component is ready
+ setTimeout(loadSavedSegments, 100);
+ }, []); // Run only once on mount
+
+ // Global click handler to close tooltips when clicking outside
+ useEffect(() => {
+ // Remove the global click handler that closes tooltips
+ // This keeps the popup always visible, even when clicking outside the timeline
+
+ // Keeping the dependency array to avoid linting errors
+ return () => {};
+ }, [selectedSegmentId, showEmptySpaceTooltip, isPlayingSegment]);
+
+ // Initialize drag handlers for trim handles
+ useEffect(() => {
+ const leftHandle = leftHandleRef.current;
+ const rightHandle = rightHandleRef.current;
+ const timeline = timelineRef.current;
+
+ if (!leftHandle || !rightHandle || !timeline) return;
+
+ const initDrag = (isLeft: boolean) => (e: MouseEvent) => {
+ e.preventDefault();
+
+ const timelineRect = timeline.getBoundingClientRect();
+ let isDragging = true;
+ let finalTime = isLeft ? trimStart : trimEnd; // Track the final time for history recording
+
+ // Use custom events to indicate drag state
+ const createCustomEvent = (type: string) => {
+ return new CustomEvent('trim-handle-event', {
+ detail: { type, isStart: isLeft },
+ });
+ };
+
+ // Dispatch start drag event to signal not to record history during drag
+ document.dispatchEvent(createCustomEvent('drag-start'));
+
+ const onMouseMove = (moveEvent: MouseEvent) => {
+ if (!isDragging) return;
+
+ const timelineWidth = timelineRect.width;
+ const position = Math.max(0, Math.min(1, (moveEvent.clientX - timelineRect.left) / timelineWidth));
+ const newTime = position * duration;
+
+ // Store position globally for iOS Safari
+ if (typeof window !== 'undefined') {
+ window.lastSeekedPosition = newTime;
+ }
+
+ if (isLeft) {
+ if (newTime < trimEnd) {
+ // Don't record in history during drag - this avoids multiple history entries
+ document.dispatchEvent(
+ new CustomEvent('update-trim', {
+ detail: {
+ time: newTime,
+ isStart: true,
+ recordHistory: false,
+ },
+ })
+ );
+ finalTime = newTime;
+ }
+ } else {
+ if (newTime > trimStart) {
+ // Don't record in history during drag - this avoids multiple history entries
+ document.dispatchEvent(
+ new CustomEvent('update-trim', {
+ detail: {
+ time: newTime,
+ isStart: false,
+ recordHistory: false,
+ },
+ })
+ );
+ finalTime = newTime;
+ }
+ }
+ };
+
+ const onMouseUp = () => {
+ isDragging = false;
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+
+ // Now record the final position in history with action type
+ if (isLeft) {
+ // Final update with history recording
+ document.dispatchEvent(
+ new CustomEvent('update-trim', {
+ detail: {
+ time: finalTime,
+ isStart: true,
+ recordHistory: true,
+ action: 'adjust_trim_start',
+ },
+ })
+ );
+ } else {
+ document.dispatchEvent(
+ new CustomEvent('update-trim', {
+ detail: {
+ time: finalTime,
+ isStart: false,
+ recordHistory: true,
+ action: 'adjust_trim_end',
+ },
+ })
+ );
+ }
+
+ // Dispatch end drag event
+ document.dispatchEvent(createCustomEvent('drag-end'));
+ };
+
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', onMouseUp);
+ };
+
+ leftHandle.addEventListener('mousedown', initDrag(true));
+ rightHandle.addEventListener('mousedown', initDrag(false));
+
+ return () => {
+ leftHandle.removeEventListener('mousedown', initDrag(true));
+ rightHandle.removeEventListener('mousedown', initDrag(false));
+ };
+ }, [duration, trimStart, trimEnd, onTrimStartChange, onTrimEndChange]);
+
+ // Render split points
+ const renderSplitPoints = () => {
+ return splitPoints.map((point, index) => {
+ const pointPercent = (point / duration) * 100;
+ return
;
+ });
+ };
+
+ // Helper function to calculate available space for a new segment
+ const calculateAvailableSpace = (startTime: number): number => {
+ // Always return at least 0.1 seconds to ensure tooltip shows
+ const MIN_SPACE = 0.1;
+
+ // Determine the amount of available space:
+ // 1. Check remaining space until the end of video
+ const remainingDuration = Math.max(0, duration - startTime);
+
+ // 2. Find the next segment (if any)
+ const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
+
+ // Find the next and previous segments
+ const nextSegment = sortedSegments.find((seg) => seg.startTime > startTime);
+ const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < startTime);
+
+ // Calculate the actual available space
+ let availableSpace;
+ if (nextSegment) {
+ // Space until next segment
+ availableSpace = nextSegment.startTime - startTime;
+ } else {
+ // Space until end of video
+ availableSpace = duration - startTime;
+ }
+
+ // Log the space calculation for debugging
+ logger.debug('Space calculation:', {
+ position: formatDetailedTime(startTime),
+ nextSegment: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
+ prevSegment: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
+ availableSpace: formatDetailedTime(Math.max(MIN_SPACE, availableSpace)),
+ });
+
+ // Always return at least MIN_SPACE to ensure tooltip shows
+ return Math.max(MIN_SPACE, availableSpace);
+ };
+
+ // Function to update tooltip based on current time position
+ const updateTooltipForPosition = (currentPosition: number) => {
+ if (!timelineRef.current) return;
+
+ // Find if we're in a segment at the current position with a small tolerance
+ const segmentAtPosition = clipSegments.find((seg) => {
+ const isWithinSegment = currentPosition >= seg.startTime && currentPosition <= seg.endTime;
+ const isVeryCloseToStart = Math.abs(currentPosition - seg.startTime) < 0.001;
+ const isVeryCloseToEnd = Math.abs(currentPosition - seg.endTime) < 0.001;
+ return isWithinSegment || isVeryCloseToStart || isVeryCloseToEnd;
+ });
+
+ // Find the next and previous segments
+ const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
+ const nextSegment = sortedSegments.find((seg) => seg.startTime > currentPosition);
+ const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < currentPosition);
+
+ if (segmentAtPosition) {
+ // We're in or exactly at a segment boundary
+ setSelectedSegmentId(segmentAtPosition.id);
+ setShowEmptySpaceTooltip(false);
+ } else {
+ // We're in a cutaway area
+ // Calculate available space for new segment
+ const availableSpace = calculateAvailableSpace(currentPosition);
+ setAvailableSegmentDuration(availableSpace);
+
+ // Always show empty space tooltip
+ setSelectedSegmentId(null);
+ setShowEmptySpaceTooltip(true);
+
+ // Log position info for debugging
+ logger.debug('Cutaway position:', {
+ current: formatDetailedTime(currentPosition),
+ prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
+ nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
+ availableSpace: formatDetailedTime(availableSpace),
+ });
+ }
+
+ // Update tooltip position
+ const rect = timelineRef.current.getBoundingClientRect();
+ const positionPercent = (currentPosition / duration) * 100;
+ let xPos;
+
+ if (zoomLevel > 1 && scrollContainerRef.current) {
+ // For zoomed timeline, adjust for scroll position
+ const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft;
+ xPos = visibleTimelineLeft + rect.width * (positionPercent / 100);
+ } else {
+ // For non-zoomed timeline, use simple calculation
+ xPos = rect.left + rect.width * (positionPercent / 100);
+ }
+
+ setTooltipPosition({
+ x: xPos,
+ y: rect.top - 10,
+ });
+ };
+
+ // Handle timeline click to seek and show a tooltip
+ const handleTimelineClick = (e: React.MouseEvent) => {
+ // Remove the check that prevents interaction during preview mode
+ // This allows users to click and jump in the timeline while previewing
+
+ if (!timelineRef.current || !scrollContainerRef.current) return;
+
+ // If on mobile device and video hasn't been initialized, don't handle timeline clicks
+ if (isIOSUninitialized) {
+ return;
+ }
+
+ // Check if video is globally playing before the click
+ const wasPlaying = videoRef.current && !videoRef.current.paused;
+ logger.debug('Video was playing before timeline click:', wasPlaying);
+
+ // Reset continuation flag when clicking on timeline - ensures proper boundary detection
+ setContinuePastBoundary(false);
+
+ const rect = timelineRef.current.getBoundingClientRect();
+
+ // Account for scroll position when calculating the click position
+ let position;
+ if (zoomLevel > 1) {
+ // When zoomed, we need to account for the scroll position
+ const scrollLeft = scrollContainerRef.current.scrollLeft;
+ const totalWidth = timelineRef.current.clientWidth;
+ position = (e.clientX - rect.left + scrollLeft) / totalWidth;
+ } else {
+ // Normal calculation for 1x zoom
+ position = (e.clientX - rect.left) / rect.width;
+ }
+
+ const newTime = position * duration;
+
+ // Log the position for debugging
+ logger.debug(
+ 'Timeline clicked at:',
+ formatDetailedTime(newTime),
+ 'distance from end:',
+ formatDetailedTime(duration - newTime)
+ );
+
+ // Store position globally for iOS Safari (this is critical for first-time visits)
+ if (typeof window !== 'undefined') {
+ window.lastSeekedPosition = newTime;
+ }
+
+ // Seek to the clicked position immediately for all clicks
+ onSeek(newTime);
+
+ // Always update both clicked time and display time for tooltip actions
+ setClickedTime(newTime);
+ setDisplayTime(newTime);
+
+ // Find if we clicked in a segment with a small tolerance for boundaries
+ const segmentAtClickedTime = clipSegments.find((seg) => {
+ // Standard check for being inside a segment
+ const isInside = newTime >= seg.startTime && newTime <= seg.endTime;
+ // Additional checks for being exactly at the start or end boundary (with small tolerance)
+ const isAtStart = Math.abs(newTime - seg.startTime) < 0.01;
+ const isAtEnd = Math.abs(newTime - seg.endTime) < 0.01;
+
+ return isInside || isAtStart || isAtEnd;
+ });
+
+ // Handle active segment assignment for boundary checking
+ if (segmentAtClickedTime) {
+ setActiveSegment(segmentAtClickedTime);
+ }
+
+ // Resume playback based on the current mode
+ if (videoRef.current) {
+ // Special handling for segments playback mode
+ if (isPlayingSegments && wasPlaying) {
+ // Update the current segment index if we clicked into a segment
+ if (segmentAtClickedTime) {
+ const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
+ const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentAtClickedTime.id);
+
+ if (targetSegmentIndex !== -1) {
+ // Dispatch a custom event to update the current segment index
+ const updateSegmentIndexEvent = new CustomEvent('update-segment-index', {
+ detail: { segmentIndex: targetSegmentIndex },
+ });
+ document.dispatchEvent(updateSegmentIndexEvent);
+ logger.debug(
+ `Segments playback mode: updating segment index to ${targetSegmentIndex} for timeline click in segment ${segmentAtClickedTime.id}`
+ );
+ }
+ }
+
+ logger.debug('Segments playback mode: resuming playback after timeline click');
+ videoRef.current
+ .play()
+ .then(() => {
+ setIsPlayingSegment(true);
+ logger.debug('Resumed segments playback after timeline seeking');
+ })
+ .catch((err) => {
+ console.error('Error resuming segments playback:', err);
+ setIsPlayingSegment(false);
+ });
+ }
+ // Resume playback if it was playing before (but not during segments playback)
+ else if (wasPlaying && !isPlayingSegments) {
+ logger.debug('Resuming playback after timeline click');
+ videoRef.current
+ .play()
+ .then(() => {
+ setIsPlayingSegment(true);
+ logger.debug('Resumed playback after seeking');
+ })
+ .catch((err) => {
+ console.error('Error resuming playback:', err);
+ setIsPlayingSegment(false);
+ });
+ }
+ }
+
+ // Only process tooltip display if clicked on the timeline background or thumbnails, not on other UI elements
+ if (e.target === timelineRef.current) {
+ // Check if there's a segment at the clicked position
+ if (segmentAtClickedTime) {
+ setSelectedSegmentId(segmentAtClickedTime.id);
+ setShowEmptySpaceTooltip(false);
+ } else {
+ // We're in a cutaway area - always show tooltip
+ setSelectedSegmentId(null);
+
+ // Calculate the available space for a new segment
+ const availableSpace = calculateAvailableSpace(newTime);
+ setAvailableSegmentDuration(availableSpace);
+
+ // Calculate and set tooltip position correctly for zoomed timeline
+ let xPos;
+ if (zoomLevel > 1) {
+ // For zoomed timeline, calculate the visible position
+ const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft;
+ const clickPosPercent = newTime / duration;
+ xPos = visibleTimelineLeft + clickPosPercent * rect.width;
+ } else {
+ // For 1x zoom, use the client X
+ xPos = e.clientX;
+ }
+
+ setTooltipPosition({
+ x: xPos,
+ y: rect.top - 10, // Position tooltip above the timeline
+ });
+
+ // Always show the empty space tooltip in cutaway areas
+ setShowEmptySpaceTooltip(true);
+
+ // Log the cutaway area details
+ const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
+ const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < newTime);
+ const nextSegment = sortedSegments.find((seg) => seg.startTime > newTime);
+
+ logger.debug('Clicked in cutaway area:', {
+ position: formatDetailedTime(newTime),
+ availableSpace: formatDetailedTime(availableSpace),
+ prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
+ nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
+ });
+ }
+ }
+ };
+
+ // Handle segment resize - works with both mouse and touch events
+ const handleSegmentResize = (segmentId: number, isLeft: boolean) => (e: React.MouseEvent | React.TouchEvent) => {
+ // Remove the check that prevents interaction during preview mode
+ // This allows users to resize segments while previewing
+
+ e.preventDefault();
+ e.stopPropagation(); // Prevent triggering parent's events
+
+ if (!timelineRef.current) return;
+
+ const timelineRect = timelineRef.current.getBoundingClientRect();
+ const timelineWidth = timelineRect.width;
+
+ // Find the segment that's being resized
+ const segment = clipSegments.find((seg) => seg.id === segmentId);
+ if (!segment) return;
+
+ const originalStartTime = segment.startTime;
+ const originalEndTime = segment.endTime;
+
+ // Store the original segment state to compare after dragging
+ const segmentBeforeDrag = { ...segment };
+
+ // Add a visual indicator that we're in resize mode (for mouse devices)
+ document.body.style.cursor = 'ew-resize';
+
+ // Add a temporary overlay to help with dragging outside the element
+ const overlay = document.createElement('div');
+ overlay.style.position = 'fixed';
+ overlay.style.top = '0';
+ overlay.style.left = '0';
+ overlay.style.width = '100vw';
+ overlay.style.height = '100vh';
+ overlay.style.zIndex = '1000';
+ overlay.style.cursor = 'ew-resize';
+ document.body.appendChild(overlay);
+
+ // Track dragging state and final positions
+ let isDragging = true;
+ let finalStartTime = originalStartTime;
+ let finalEndTime = originalEndTime;
+
+ // Dispatch an event to signal drag start
+ document.dispatchEvent(
+ new CustomEvent('segment-drag-start', {
+ detail: { segmentId },
+ })
+ );
+
+ // Keep the tooltip visible during drag
+ // Function to handle both mouse and touch movements
+ const handleDragMove = (clientX: number) => {
+ if (!isDragging || !timelineRef.current) return;
+
+ const updatedTimelineRect = timelineRef.current.getBoundingClientRect();
+ const position = Math.max(0, Math.min(1, (clientX - updatedTimelineRect.left) / updatedTimelineRect.width));
+ const newTime = position * duration;
+
+ // Check if the current marker position intersects with where the segment will be
+ const currentSegmentStart = isLeft ? newTime : originalStartTime;
+ const currentSegmentEnd = isLeft ? originalEndTime : newTime;
+ const isMarkerInSegment = currentTime >= currentSegmentStart && currentTime <= currentSegmentEnd;
+
+ // Update tooltip based on marker intersection
+ if (isMarkerInSegment) {
+ // Show segment tooltip if marker is inside the segment
+ setSelectedSegmentId(segmentId);
+ setShowEmptySpaceTooltip(false);
+ } else {
+ // Show cutaway tooltip if marker is outside the segment
+ setSelectedSegmentId(null);
+ // Calculate available space for cutaway tooltip
+ const availableSpace = calculateAvailableSpace(currentTime);
+ setAvailableSegmentDuration(availableSpace);
+ setShowEmptySpaceTooltip(true);
+ }
+
+ // Find neighboring segments (exclude the current one)
+ const otherSegments = clipSegments.filter((seg) => seg.id !== segmentId);
+
+ // Calculate new start/end times based on drag direction
+ let newStartTime = originalStartTime;
+ let newEndTime = originalEndTime;
+
+ if (isLeft) {
+ // Dragging left handle - adjust start time
+ newStartTime = Math.min(newTime, originalEndTime - 0.5);
+
+ // Find the closest left neighbor
+ const leftNeighbors = otherSegments
+ .filter((seg) => seg.endTime <= originalStartTime)
+ .sort((a, b) => b.endTime - a.endTime);
+
+ const leftNeighbor = leftNeighbors[0];
+
+ // Prevent overlapping with left neighbor
+ if (leftNeighbor && newStartTime < leftNeighbor.endTime) {
+ newStartTime = leftNeighbor.endTime;
+ }
+
+ // Snap to the nearest segment with a small threshold
+ const snapThreshold = 0.3; // seconds
+
+ if (leftNeighbor && Math.abs(newStartTime - leftNeighbor.endTime) < snapThreshold) {
+ newStartTime = leftNeighbor.endTime;
+ }
+
+ // Update final value for history recording
+ finalStartTime = newStartTime;
+ } else {
+ // Dragging right handle - adjust end time
+ newEndTime = Math.max(newTime, originalStartTime + 0.5);
+
+ // Find the closest right neighbor
+ const rightNeighbors = otherSegments
+ .filter((seg) => seg.startTime >= originalEndTime)
+ .sort((a, b) => a.startTime - b.startTime);
+
+ const rightNeighbor = rightNeighbors[0];
+
+ // Prevent overlapping with right neighbor
+ if (rightNeighbor && newEndTime > rightNeighbor.startTime) {
+ newEndTime = rightNeighbor.startTime;
+ }
+
+ // Snap to the nearest segment with a small threshold
+ const snapThreshold = 0.3; // seconds
+
+ if (rightNeighbor && Math.abs(newEndTime - rightNeighbor.startTime) < snapThreshold) {
+ newEndTime = rightNeighbor.startTime;
+ }
+
+ // Update final value for history recording
+ finalEndTime = newEndTime;
+ }
+
+ // Create a new segments array with the updated segment
+ const updatedSegments = clipSegments.map((seg) => {
+ if (seg.id === segmentId) {
+ return {
+ ...seg,
+ startTime: newStartTime,
+ endTime: newEndTime,
+ };
+ }
+ return seg;
+ });
+
+ // Create a custom event to update the segments WITHOUT recording in history during drag
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: updatedSegments,
+ recordHistory: false, // Don't record intermediate states
+ },
+ });
+ document.dispatchEvent(updateEvent);
+
+ // During dragging, check if the current tooltip needs to be updated based on segment position
+ if (selectedSegmentId === segmentId && videoRef.current) {
+ const currentTime = videoRef.current.currentTime;
+ const segment = updatedSegments.find((seg) => seg.id === segmentId);
+
+ if (segment) {
+ // Check if playhead position is now outside the segment after dragging
+ const isInsideSegment = currentTime >= segment.startTime && currentTime <= segment.endTime;
+
+ // Log the current position information for debugging
+ logger.debug(
+ `During drag - playhead at ${formatDetailedTime(currentTime)} is ${
+ isInsideSegment ? 'inside' : 'outside'
+ } segment (${formatDetailedTime(segment.startTime)} - ${formatDetailedTime(segment.endTime)})`
+ );
+
+ if (!isInsideSegment && isPlayingSegment) {
+ logger.debug('Playhead position is outside segment after dragging - updating tooltip');
+ // Stop playback if we were playing and dragged the segment away from playhead
+ videoRef.current.pause();
+ setIsPlayingSegment(false);
+ setActiveSegment(null);
+ }
+
+ // Update display time to stay in bounds of the segment
+ if (currentTime < segment.startTime) {
+ logger.debug(
+ `Adjusting display time to segment start: ${formatDetailedTime(segment.startTime)}`
+ );
+ setDisplayTime(segment.startTime);
+
+ // Update UI state to reflect that playback will be from segment start
+ setClickedTime(segment.startTime);
+ } else if (currentTime > segment.endTime) {
+ logger.debug(`Adjusting display time to segment end: ${formatDetailedTime(segment.endTime)}`);
+ setDisplayTime(segment.endTime);
+
+ // Update UI state to reflect that playback will be from segment end
+ setClickedTime(segment.endTime);
+ }
+ }
+ }
+ };
+
+ // Function to handle the end of dragging (for both mouse and touch)
+ const handleDragEnd = () => {
+ if (!isDragging) return;
+
+ isDragging = false;
+
+ // Clean up event listeners for both mouse and touch
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ document.removeEventListener('touchmove', handleTouchMove);
+ document.removeEventListener('touchend', handleTouchEnd);
+ document.removeEventListener('touchcancel', handleTouchEnd);
+
+ // Reset styles
+ document.body.style.cursor = '';
+ if (document.body.contains(overlay)) {
+ document.body.removeChild(overlay);
+ }
+
+ // Record the final position in history as a single action
+ const finalSegments = clipSegments.map((seg) => {
+ if (seg.id === segmentId) {
+ return {
+ ...seg,
+ startTime: finalStartTime,
+ endTime: finalEndTime,
+ };
+ }
+ return seg;
+ });
+
+ // Now we can create a history record for the complete drag operation
+ const actionType = isLeft ? 'adjust_segment_start' : 'adjust_segment_end';
+ document.dispatchEvent(
+ new CustomEvent('update-segments', {
+ detail: {
+ segments: finalSegments,
+ recordHistory: true,
+ action: actionType,
+ },
+ })
+ );
+
+ // After drag is complete, do a final check to see if playhead is inside the segment
+ if (selectedSegmentId === segmentId && videoRef.current) {
+ const currentTime = videoRef.current.currentTime;
+ const segment = finalSegments.find((seg) => seg.id === segmentId);
+
+ if (segment) {
+ const isInsideSegment = currentTime >= segment.startTime && currentTime <= segment.endTime;
+
+ logger.debug(
+ `Drag complete - playhead at ${formatDetailedTime(currentTime)} is ${
+ isInsideSegment ? 'inside' : 'outside'
+ } segment (${formatDetailedTime(segment.startTime)} - ${formatDetailedTime(segment.endTime)})`
+ );
+
+ // Check if playhead status changed during drag
+ const wasInsideSegmentBefore =
+ currentTime >= segmentBeforeDrag.startTime && currentTime <= segmentBeforeDrag.endTime;
+
+ logger.debug(
+ `Playhead was ${
+ wasInsideSegmentBefore ? 'inside' : 'outside'
+ } segment before drag, now ${isInsideSegment ? 'inside' : 'outside'}`
+ );
+
+ // Update UI elements based on segment position
+ if (!isInsideSegment) {
+ // If we were playing and the playhead is now outside the segment, stop playback
+ if (isPlayingSegment) {
+ videoRef.current.pause();
+ setIsPlayingSegment(false);
+ setActiveSegment(null);
+ setContinuePastBoundary(false);
+ logger.debug('Stopped playback because playhead is outside segment after drag completion');
+ }
+
+ // Update display time to be within the segment's bounds
+ if (currentTime < segment.startTime) {
+ logger.debug(
+ `Final adjustment - setting display time to segment start: ${formatDetailedTime(
+ segment.startTime
+ )}`
+ );
+ setDisplayTime(segment.startTime);
+ setClickedTime(segment.startTime);
+ } else if (currentTime > segment.endTime) {
+ logger.debug(
+ `Final adjustment - setting display time to segment end: ${formatDetailedTime(
+ segment.endTime
+ )}`
+ );
+ setDisplayTime(segment.endTime);
+ setClickedTime(segment.endTime);
+ }
+ }
+ // Special case: playhead was outside segment before, but now it's inside - can start playback
+ else if (!wasInsideSegmentBefore && isInsideSegment) {
+ logger.debug('Playhead moved INTO segment during drag - can start playback');
+ setActiveSegment(segment);
+ }
+ // Another special case: playhead was inside segment before, but now is also inside but at a different position
+ else if (
+ wasInsideSegmentBefore &&
+ isInsideSegment &&
+ (segment.startTime !== segmentBeforeDrag.startTime ||
+ segment.endTime !== segmentBeforeDrag.endTime)
+ ) {
+ logger.debug(
+ 'Segment boundaries changed while playhead remained inside - updating activeSegment'
+ );
+ // Update the active segment reference to ensure boundary detection works with new bounds
+ setActiveSegment(segment);
+ }
+ }
+ }
+ };
+
+ // Mouse-specific event handlers
+ const handleMouseMove = (moveEvent: MouseEvent) => {
+ handleDragMove(moveEvent.clientX);
+ };
+
+ const handleMouseUp = () => {
+ handleDragEnd();
+ };
+
+ // Touch-specific event handlers
+ const handleTouchMove = (moveEvent: TouchEvent) => {
+ if (moveEvent.touches.length > 0) {
+ moveEvent.preventDefault(); // Prevent scrolling while dragging
+ handleDragMove(moveEvent.touches[0].clientX);
+ }
+ };
+
+ const handleTouchEnd = () => {
+ handleDragEnd();
+ };
+
+ // Register event listeners for both mouse and touch
+ document.addEventListener('mousemove', handleMouseMove, {
+ passive: false,
+ });
+ document.addEventListener('mouseup', handleMouseUp);
+ document.addEventListener('touchmove', handleTouchMove, {
+ passive: false,
+ });
+ document.addEventListener('touchend', handleTouchEnd);
+ document.addEventListener('touchcancel', handleTouchEnd);
+ };
+
+ // Handle segment click to show the tooltip
+ const handleSegmentClick = (segmentId: number) => (e: React.MouseEvent) => {
+ // Remove the check that prevents interaction during preview mode
+ // This allows users to click segments while previewing
+
+ // Don't show tooltip if clicked on handle
+ if ((e.target as HTMLElement).classList.contains('clip-segment-handle')) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ logger.debug('Segment clicked:', segmentId);
+
+ // Reset continuation flag when selecting a segment - ensures proper boundary detection
+ setContinuePastBoundary(false);
+
+ // Check if video is currently playing before clicking
+ const wasPlaying = videoRef.current && !videoRef.current.paused;
+ logger.debug('seekVideo: Was playing before:', wasPlaying);
+
+ // Set the current segment as selected
+ setSelectedSegmentId(segmentId);
+
+ // Find the segment in our data
+ const segment = clipSegments.find((seg) => seg.id === segmentId);
+ if (!segment) return;
+
+ // Find the segment element in the DOM
+ const segmentElement = e.currentTarget as HTMLElement;
+ const segmentRect = segmentElement.getBoundingClientRect();
+
+ // Calculate relative click position within the segment (0 to 1)
+ const relativeX = (e.clientX - segmentRect.left) / segmentRect.width;
+
+ // Convert to time based on segment's start and end times
+ const clickTime = segment.startTime + relativeX * (segment.endTime - segment.startTime);
+
+ // Ensure time is within segment bounds
+ const boundedTime = Math.max(segment.startTime, Math.min(segment.endTime, clickTime));
+
+ // Set both clicked time and display time for UI
+ setClickedTime(boundedTime);
+ setDisplayTime(boundedTime);
+
+ // Check if the video's current time is inside or outside the segment
+ // This helps with updating the tooltip correctly after dragging operations
+ if (videoRef.current) {
+ const currentVideoTime = videoRef.current.currentTime;
+ const isPlayheadInsideSegment =
+ currentVideoTime >= segment.startTime && currentVideoTime <= segment.endTime;
+
+ logger.debug(
+ `Segment click - playhead at ${formatDetailedTime(currentVideoTime)} is ${
+ isPlayheadInsideSegment ? 'inside' : 'outside'
+ } segment (${formatDetailedTime(segment.startTime)} - ${formatDetailedTime(segment.endTime)})`
+ );
+
+ // If playhead is outside the segment, update the display time to segment boundary
+ if (!isPlayheadInsideSegment) {
+ // Adjust the display time based on which end is closer to the playhead
+ if (Math.abs(currentVideoTime - segment.startTime) < Math.abs(currentVideoTime - segment.endTime)) {
+ // Playhead is closer to segment start
+ logger.debug(
+ `Playhead outside segment - adjusting to segment start: ${formatDetailedTime(
+ segment.startTime
+ )}`
+ );
+ setDisplayTime(segment.startTime);
+ // Don't update clickedTime here since we already set it to the clicked position
+ } else {
+ // Playhead is closer to segment end
+ logger.debug(
+ `Playhead outside segment - adjusting to segment end: ${formatDetailedTime(segment.endTime)}`
+ );
+ setDisplayTime(segment.endTime);
+ // Don't update clickedTime here since we already set it to the clicked position
+ }
+ }
+ }
+
+ // Seek to this position (this will update the video's current time)
+ onSeek(boundedTime);
+
+ // Handle playback continuation based on the current mode
+ if (videoRef.current) {
+ // Special handling for segments playback mode
+ if (isPlayingSegments && wasPlaying) {
+ // Update the current segment index for segments playback mode
+ const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
+ const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentId);
+
+ if (targetSegmentIndex !== -1) {
+ // Dispatch a custom event to update the current segment index
+ const updateSegmentIndexEvent = new CustomEvent('update-segment-index', {
+ detail: { segmentIndex: targetSegmentIndex },
+ });
+ document.dispatchEvent(updateSegmentIndexEvent);
+ logger.debug(
+ `Segments playback mode: updating segment index to ${targetSegmentIndex} for segment ${segmentId}`
+ );
+ }
+
+ // In segments playback mode, we want to continue the segments playback from the new position
+ // The segments playback will naturally handle continuing to the next segments
+ logger.debug('Segments playback mode: continuing playback from new position');
+ videoRef.current
+ .play()
+ .then(() => {
+ setIsPlayingSegment(true);
+ logger.debug('Continued segments playback after segment click');
+ })
+ .catch((err) => {
+ console.error('Error continuing segments playback after segment click:', err);
+ });
+ }
+ // If video was playing before, ensure it continues playing (but not in segments mode)
+ else if (wasPlaying && !isPlayingSegments) {
+ // Set current segment as active segment for boundary checking
+ setActiveSegment(segment);
+ // Reset the continuePastBoundary flag when clicking on a segment to ensure boundaries work
+ setContinuePastBoundary(false);
+ // Continue playing from the new position
+ videoRef.current
+ .play()
+ .then(() => {
+ setIsPlayingSegment(true);
+ logger.debug('Continued preview playback after segment click');
+ })
+ .catch((err) => {
+ console.error('Error resuming playback after segment click:', err);
+ });
+ }
+ }
+
+ // Calculate tooltip position directly above click point
+ const tooltipX = e.clientX;
+ const tooltipY = segmentRect.top - 10;
+
+ setTooltipPosition({
+ x: tooltipX,
+ y: tooltipY,
+ });
+
+ // Auto-scroll to center the clicked position for zoomed timeline
+ if (zoomLevel > 1 && timelineRef.current && scrollContainerRef.current) {
+ const timelineRect = timelineRef.current.getBoundingClientRect();
+ const timelineWidth = timelineRef.current.clientWidth;
+ const containerWidth = scrollContainerRef.current.clientWidth;
+
+ // Calculate pixel position of clicked time
+ const clickedPosPixel = (boundedTime / duration) * timelineWidth;
+
+ // Center the view on the clicked position
+ const targetScrollLeft = Math.max(0, clickedPosPixel - containerWidth / 2);
+
+ // Smooth scroll to the clicked point
+ scrollContainerRef.current.scrollTo({
+ left: targetScrollLeft,
+ behavior: 'smooth',
+ });
+
+ // Update tooltip position after scrolling completes
+ setTimeout(() => {
+ if (timelineRef.current && scrollContainerRef.current) {
+ // Calculate new position based on viewport
+ const updatedRect = timelineRef.current.getBoundingClientRect();
+ const timePercent = boundedTime / duration;
+ const newPosition =
+ timePercent * timelineWidth - scrollContainerRef.current.scrollLeft + updatedRect.left;
+
+ setTooltipPosition({
+ x: newPosition,
+ y: tooltipY,
+ });
+ }
+ }, 300); // Wait for smooth scrolling to complete
+ }
+
+ // We no longer need a local click handler as we have a global one
+ // that handles closing tooltips when clicking outside
+ };
+
+ // Show tooltip for the segment
+ const setShowTooltip = (show: boolean, segmentId: number, x: number, y: number) => {
+ setSelectedSegmentId(show ? segmentId : null);
+ setTooltipPosition({ x, y });
+ };
+
+ // Render the clip segments on the timeline
+ const renderClipSegments = () => {
+ // Sort segments by start time to ensure correct chronological order
+ const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
+ return sortedSegments.map((segment, index) => {
+ const startPercent = (segment.startTime / duration) * 100;
+ const widthPercent = ((segment.endTime - segment.startTime) / duration) * 100;
+
+ // Generate a solid background color based on segment position
+ const backgroundColor = generateSolidColor((segment.startTime + segment.endTime) / 2, duration);
+
+ return (
+
+
+
Chapter {index + 1}
+
+ {formatTime(segment.startTime)} - {formatTime(segment.endTime)}
+
+
+ Duration: {formatTime(segment.endTime - segment.startTime)}
+
+
+
+ {/* Resize handles with both mouse and touch support */}
+ {isPlayingSegments ? null : (
+ <>
+
{
+ e.stopPropagation();
+ handleSegmentResize(segment.id, true)(e);
+ }}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ handleSegmentResize(segment.id, true)(e);
+ }}
+ >
+
{
+ e.stopPropagation();
+ handleSegmentResize(segment.id, false)(e);
+ }}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ handleSegmentResize(segment.id, false)(e);
+ }}
+ >
+ >
+ )}
+
+ );
+ });
+ };
+
+ // Add a new useEffect hook to listen for segment deletion events
+ useEffect(() => {
+ const handleSegmentDelete = (event: CustomEvent) => {
+ const { segmentId } = event.detail;
+
+ // Check if this was the last segment before deletion
+ const remainingSegments = clipSegments.filter((seg) => seg.id !== segmentId);
+ if (remainingSegments.length === 0) {
+ // Allow empty state - clear all UI state
+ setSelectedSegmentId(null);
+ setShowEmptySpaceTooltip(false);
+ setActiveSegment(null);
+
+ logger.debug('All segments deleted - entering empty state');
+ } else if (selectedSegmentId === segmentId) {
+ // Handle normal segment deletion
+ const deletedSegment = clipSegments.find((seg) => seg.id === segmentId);
+ if (!deletedSegment) return;
+
+ // Calculate available space after deletion
+ const availableSpace = calculateAvailableSpace(currentTime);
+
+ // Update UI to show cutaway tooltip
+ setSelectedSegmentId(null);
+ setShowEmptySpaceTooltip(true);
+ setAvailableSegmentDuration(availableSpace);
+
+ // Calculate tooltip position
+ if (timelineRef.current) {
+ const rect = timelineRef.current.getBoundingClientRect();
+ const posPercent = (currentTime / duration) * 100;
+ const xPosition = rect.left + rect.width * (posPercent / 100);
+
+ setTooltipPosition({
+ x: xPosition,
+ y: rect.top - 10,
+ });
+
+ logger.debug('Segment deleted, showing cutaway tooltip:', {
+ position: formatDetailedTime(currentTime),
+ availableSpace: formatDetailedTime(availableSpace),
+ });
+ }
+ }
+ };
+
+ // Add event listener for the custom delete-segment event
+ document.addEventListener('delete-segment', handleSegmentDelete as EventListener);
+
+ // Clean up event listener on component unmount
+ return () => {
+ document.removeEventListener('delete-segment', handleSegmentDelete as EventListener);
+ };
+ }, [selectedSegmentId, clipSegments, currentTime, duration, timelineRef]);
+
+ // Add an effect to synchronize tooltip play state with video play state
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ const handlePlay = () => {
+ // Simple play handler - just update UI state, no boundary checking
+ setIsPlaying(true);
+ setIsPlayingSegment(true);
+ logger.debug('Continuous playback started from TimelineControls');
+ };
+
+ const handlePause = () => {
+ logger.debug('Video paused from external control');
+ setIsPlaying(false);
+ setIsPlayingSegment(false);
+ };
+
+ video.addEventListener('play', handlePlay);
+ video.addEventListener('pause', handlePause);
+
+ return () => {
+ video.removeEventListener('play', handlePlay);
+ video.removeEventListener('pause', handlePause);
+ };
+ }, []);
+
+ // Handle mouse movement over timeline to remember position
+ const handleTimelineMouseMove = (e: React.MouseEvent) => {
+ if (!timelineRef.current) return;
+
+ const rect = timelineRef.current.getBoundingClientRect();
+ const position = (e.clientX - rect.left) / rect.width;
+ const time = position * duration;
+
+ // Ensure time is within bounds
+ const boundedTime = Math.max(0, Math.min(duration, time));
+
+ // Store position globally for iOS Safari
+ if (typeof window !== 'undefined') {
+ window.lastSeekedPosition = boundedTime;
+ }
+ };
+
+ // Add the dragging state and handlers to the component
+
+ // Inside the TimelineControls component, add these new state variables
+ const [isDragging, setIsDragging] = useState(false);
+ // Add a dragging ref to track state without relying on React's state updates
+ const isDraggingRef = useRef(false);
+
+ // Add drag handlers to enable dragging the timeline marker
+ const startDrag = (e: React.MouseEvent | React.TouchEvent) => {
+ // If on mobile device and video hasn't been initialized, don't allow dragging
+ if (isIOSUninitialized) {
+ return;
+ }
+
+ e.stopPropagation(); // Don't trigger the timeline click
+ e.preventDefault(); // Prevent text selection during drag
+
+ setIsDragging(true);
+ isDraggingRef.current = true; // Use ref for immediate value access
+
+ // Show tooltip immediately when starting to drag
+ updateTooltipForPosition(currentTime);
+
+ // Handle mouse events
+ const handleMouseMove = (moveEvent: MouseEvent) => {
+ if (!timelineRef.current || !scrollContainerRef.current) return;
+
+ // Calculate the position based on mouse or touch coordinates
+ const rect = timelineRef.current.getBoundingClientRect();
+ let position;
+
+ if (zoomLevel > 1) {
+ // When zoomed, account for scroll position
+ const scrollLeft = scrollContainerRef.current.scrollLeft;
+ const totalWidth = timelineRef.current.clientWidth;
+ position = (moveEvent.clientX - rect.left + scrollLeft) / totalWidth;
+ } else {
+ // Normal calculation for 1x zoom
+ position = (moveEvent.clientX - rect.left) / rect.width;
+ }
+
+ // Constrain position between 0 and 1
+ position = Math.max(0, Math.min(1, position));
+
+ // Convert to time and seek
+ const newTime = position * duration;
+
+ // Update both clicked time and display time
+ setClickedTime(newTime);
+ setDisplayTime(newTime);
+
+ // Update tooltip state based on new position
+ updateTooltipForPosition(newTime);
+
+ // Store position globally for iOS Safari
+ if (typeof window !== 'undefined') {
+ (window as any).lastSeekedPosition = newTime;
+ }
+
+ // Seek to the new position
+ onSeek(newTime);
+ };
+
+ // Handle mouse up to stop dragging
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ isDraggingRef.current = false; // Update ref immediately
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+
+ // Add event listeners to track movement and release
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ };
+
+ // Handle touch events for mobile devices
+ const startTouchDrag = (e: React.TouchEvent) => {
+ // If on mobile device and video hasn't been initialized, don't allow dragging
+ if (isIOSUninitialized) {
+ return;
+ }
+
+ e.stopPropagation(); // Don't trigger the timeline click
+ e.preventDefault(); // Prevent text selection during drag
+
+ setIsDragging(true);
+ isDraggingRef.current = true; // Use ref for immediate value access
+
+ // Show tooltip immediately when starting to drag
+ updateTooltipForPosition(currentTime);
+
+ // Handle touch move events
+ const handleTouchMove = (moveEvent: TouchEvent) => {
+ if (!timelineRef.current || !scrollContainerRef.current || !moveEvent.touches[0]) return;
+
+ // Calculate the position based on touch coordinates
+ const rect = timelineRef.current.getBoundingClientRect();
+ let position;
+
+ if (zoomLevel > 1) {
+ // When zoomed, account for scroll position
+ const scrollLeft = scrollContainerRef.current.scrollLeft;
+ const totalWidth = timelineRef.current.clientWidth;
+ position = (moveEvent.touches[0].clientX - rect.left + scrollLeft) / totalWidth;
+ } else {
+ // Normal calculation for 1x zoom
+ position = (moveEvent.touches[0].clientX - rect.left) / rect.width;
+ }
+
+ // Constrain position between 0 and 1
+ position = Math.max(0, Math.min(1, position));
+
+ // Convert to time and seek
+ const newTime = position * duration;
+
+ // Update both clicked time and display time
+ setClickedTime(newTime);
+ setDisplayTime(newTime);
+
+ // Update tooltip state based on new position
+ updateTooltipForPosition(newTime);
+
+ // Store position globally for mobile browsers
+ if (typeof window !== 'undefined') {
+ (window as any).lastSeekedPosition = newTime;
+ }
+
+ // Seek to the new position
+ onSeek(newTime);
+ };
+
+ // Handle touch end to stop dragging
+ const handleTouchEnd = () => {
+ setIsDragging(false);
+ isDraggingRef.current = false; // Update ref immediately
+ document.removeEventListener('touchmove', handleTouchMove);
+ document.removeEventListener('touchend', handleTouchEnd);
+ document.removeEventListener('touchcancel', handleTouchEnd);
+ };
+
+ // Add event listeners to track movement and release
+ document.addEventListener('touchmove', handleTouchMove, {
+ passive: false,
+ });
+ document.addEventListener('touchend', handleTouchEnd);
+ document.addEventListener('touchcancel', handleTouchEnd);
+ };
+
+ // Add a useEffect to log the redirect URL whenever it changes
+ useEffect(() => {
+ if (redirectUrl) {
+ logger.debug('Redirect URL updated:', {
+ redirectUrl,
+ saveType,
+ isSuccessModalOpen: showSuccessModal,
+ });
+ }
+ }, [redirectUrl, saveType, showSuccessModal]);
+
+
+ // Note: Removed the conflicting redirect effect - redirect is now handled by cancelRedirect function
+
+ return (
+
+ {/* Current Timecode with Milliseconds */}
+
+
+ Timeline
+
+
+ Total Chapters:{' '}
+
+ {formatDetailedTime(
+ clipSegments.reduce((sum, segment) => sum + (segment.endTime - segment.startTime), 0)
+ )}
+
+
+
+
+ {/* Timeline Container with Scrollable Wrapper */}
+
1 ? 'auto' : 'hidden',
+ }}
+ >
+
+ {/* Current Position Marker */}
+
+ {/* Top circle for popup toggle */}
+
{
+ // Prevent event propagation to avoid triggering the timeline container click
+ e.stopPropagation();
+
+ // For ensuring accurate segment detection, refresh clipSegments first
+ // This helps when clicking right after creating a new segment
+ const refreshedSegmentAtCurrentTime = clipSegments.find(
+ (seg) => currentTime >= seg.startTime && currentTime <= seg.endTime
+ );
+
+ // Toggle tooltip visibility with a single click
+ if (selectedSegmentId || showEmptySpaceTooltip) {
+ // When tooltip is open and - icon is clicked, simply close the tooltips
+ logger.debug('Closing tooltip');
+ setSelectedSegmentId(null);
+ setShowEmptySpaceTooltip(false);
+ // Don't reopen the tooltip - just leave it closed
+ return;
+ } else {
+ // Use our improved tooltip position logic
+ updateTooltipForPosition(currentTime);
+ logger.debug('Opening tooltip at:', formatDetailedTime(currentTime));
+ }
+ }}
+ >
+
+ {selectedSegmentId || showEmptySpaceTooltip ? '-' : '+'}
+
+
+
+ {/* Bottom circle for dragging */}
+ {isPlayingSegments ? null : (
+
+ ⋮
+
+ )}
+
+
+ {/* Trim Line Markers - hidden when segments exist */}
+ {clipSegments.length === 0 && (
+ <>
+
+
+ >
+ )}
+
+ {/* Clip Segments */}
+ {renderClipSegments()}
+
+ {/* Split Points */}
+ {renderSplitPoints()}
+
+ {/* Segment Tooltip */}
+ {selectedSegmentId !== null && (
+
{
+ if (isPlayingSegments) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ }}
+ >
+ {/* Chapter Editor for this segment */}
+ {selectedSegmentId && (
+
+
+ )}
+
+ {/* First row with time adjustment buttons */}
+
+
+ -50ms
+
+
{
+ if (isPlayingSegments) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ }}
+ >
+ {formatDetailedTime(displayTime)}
+
+
+ +50ms
+
+
+ {/* Second row with action buttons */}
+
+
{
+ e.stopPropagation();
+ // Call the delete segment function with the current segment ID
+ const deleteEvent = new CustomEvent('delete-segment', {
+ detail: {
+ segmentId: selectedSegmentId,
+ },
+ });
+ document.dispatchEvent(deleteEvent);
+ // We don't need to manually close the tooltip - our event handler will take care of updating the UI
+ }}
+ >
+
+
+
+
+
+
+
+
{
+ e.stopPropagation();
+ // Call the split segment function with the current segment ID and time
+ const splitEvent = new CustomEvent('split-segment', {
+ detail: {
+ segmentId: selectedSegmentId,
+ time: clickedTime,
+ },
+ });
+ document.dispatchEvent(splitEvent);
+ // Keep the tooltip open
+ // setSelectedSegmentId(null);
+ }}
+ >
+
+
+
+
+
+
+
+
+
{
+ e.stopPropagation();
+
+ // Find the selected segment
+ const segment = clipSegments.find((seg) => seg.id === selectedSegmentId);
+ if (segment && videoRef.current) {
+ // Enable continuePastBoundary flag when user explicitly clicks play
+ // This will allow playback to continue even if we're at segment boundary
+ setContinuePastBoundary(true);
+ logger.debug(
+ 'Setting continuePastBoundary=true to allow playback through boundaries'
+ );
+
+ // Special handling for when we're at the end of the segment already
+ // Check if we're at or extremely close to the end boundary
+ if (Math.abs(videoRef.current.currentTime - segment.endTime) < 0.05) {
+ logger.debug(
+ `Already at end boundary (${formatDetailedTime(
+ videoRef.current.currentTime
+ )}), nudging position back slightly`
+ );
+ const newPosition = Math.max(segment.startTime, segment.endTime - 0.1); // Move 100ms back from end
+ videoRef.current.currentTime = newPosition;
+ onSeek(newPosition);
+ setClickedTime(newPosition);
+ logger.debug(`Position adjusted to ${formatDetailedTime(newPosition)}`);
+ } else {
+ // Normal case - just seek to the start of the segment
+ onSeek(segment.startTime);
+ setClickedTime(segment.startTime);
+ }
+
+ // Set active segment for boundary checking before playing
+ setActiveSegment(segment);
+
+ // Start playing from the beginning of the segment with proper promise handling
+ videoRef.current
+ .play()
+ .then(() => {
+ setIsPlayingSegment(true);
+ logger.debug('Playing from beginning of segment');
+ })
+ .catch((err) => {
+ console.error('Error playing from beginning:', err);
+ });
+ }
+
+ // Don't close the tooltip
+ }}
+ >
+
+
+ {/*
{
+ e.stopPropagation();
+
+ // Find the current segment
+ const currentSegment = clipSegments.find(seg =>
+ currentTime >= seg.startTime && currentTime <= seg.endTime
+ );
+
+ if (isPlaying) {
+ // If playing, just pause
+ if (videoRef.current) {
+ videoRef.current.pause();
+ setIsPlayingSegment(false);
+ setContinuePastBoundary(false);
+ }
+ } else {
+ // If starting playback, set the active segment
+ if (currentSegment) {
+ setActiveSegment(currentSegment);
+ }
+
+ // Reset continuation flag when starting new playback
+ setContinuePastBoundary(false);
+
+ if (videoRef.current) {
+ videoRef.current.play()
+ .then(() => {
+ setIsPlayingSegment(true);
+ })
+ .catch(err => {
+ console.error("Error playing video:", err);
+ setIsPlayingSegment(false);
+ });
+ }
+ }
+ }}
+ >
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+ */}
+
+ {/* Play/Pause button for empty space - Same as main play/pause button */}
+
{
+ e.stopPropagation();
+
+ if (isPlaying) {
+ // If playing, just pause
+ if (videoRef.current) {
+ videoRef.current.pause();
+ setIsPlayingSegment(false);
+ setContinuePastBoundary(false);
+ }
+ } else {
+ onPlayPause();
+ }
+ }}
+ >
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+
{
+ e.stopPropagation();
+
+ // Find the selected segment and update its start time
+ const segment = clipSegments.find((seg) => seg.id === selectedSegmentId);
+ if (segment) {
+ // Create updated segments with new start time for selected segment
+ const updatedSegments = clipSegments.map((seg) => {
+ if (seg.id === selectedSegmentId) {
+ return {
+ ...seg,
+ startTime:
+ clickedTime < seg.endTime - 0.5
+ ? clickedTime
+ : seg.endTime - 0.5,
+ };
+ }
+ return seg;
+ });
+
+ // Create and dispatch the update event
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: updatedSegments,
+ recordHistory: true, // Ensure this specific action is recorded in history
+ action: 'adjust_start_time',
+ },
+ });
+ document.dispatchEvent(updateEvent);
+ logger.debug('Set in clicked');
+ }
+
+ // Keep tooltip open
+ // setSelectedSegmentId(null);
+ }}
+ >
+
+
+
{
+ e.stopPropagation();
+
+ // Find the selected segment and update its end time
+ const segment = clipSegments.find((seg) => seg.id === selectedSegmentId);
+ if (segment) {
+ // Create updated segments with new end time for selected segment
+ const updatedSegments = clipSegments.map((seg) => {
+ if (seg.id === selectedSegmentId) {
+ return {
+ ...seg,
+ endTime:
+ clickedTime > seg.startTime + 0.5
+ ? clickedTime
+ : seg.startTime + 0.5,
+ };
+ }
+ return seg;
+ });
+
+ // Create and dispatch the update event
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: updatedSegments,
+ recordHistory: true, // Ensure this specific action is recorded in history
+ action: 'adjust_end_time',
+ },
+ });
+ document.dispatchEvent(updateEvent);
+ logger.debug('Set out clicked');
+ }
+
+ // Keep the tooltip open
+ // setSelectedSegmentId(null);
+ }}
+ >
+
+
+
+
+ )}
+
+ {/* Empty space tooltip - positioned absolutely within timeline container */}
+ {showEmptySpaceTooltip && selectedSegmentId === null && (
+
{
+ if (isPlayingSegments) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ }}
+ >
+ {/* First row with time adjustment buttons - same as segment tooltip */}
+
+
+ -50ms
+
+
{
+ if (isPlayingSegments) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ }}
+ >
+ {formatDetailedTime(clickedTime)}
+
+
+ +50ms
+
+
+
+ {/* Second row with action buttons similar to segment tooltip */}
+
+ {/* New segment button - Moved to first position */}
+
{
+ e.stopPropagation();
+
+ // Only create if we have at least 0.5 seconds of space
+ if (availableSegmentDuration < 0.5) {
+ // Not enough space, do nothing
+ return;
+ }
+
+ // Create a new segment with the calculated available duration
+ const segmentStartTime = clickedTime;
+ const segmentEndTime = segmentStartTime + availableSegmentDuration;
+
+ // Create the new segment with proper chapter name
+ const newSegment: Segment = {
+ id: Date.now(),
+ chapterTitle: generateChapterName(segmentStartTime, clipSegments),
+ startTime: segmentStartTime,
+ endTime: segmentEndTime,
+ };
+
+ // Add the new segment to existing segments
+ const updatedSegments = [...clipSegments, newSegment];
+
+ // Create and dispatch the update event
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: updatedSegments,
+ recordHistory: true, // Explicitly record this action in history
+ action: 'create_segment',
+ },
+ });
+ document.dispatchEvent(updateEvent);
+
+ // Close empty space tooltip
+ setShowEmptySpaceTooltip(false);
+
+ // After creating the segment, wait a short time for the state to update
+ setTimeout(() => {
+ // The newly created segment is the last one in the array with the ID we just assigned
+ const createdSegment = updatedSegments[updatedSegments.length - 1];
+
+ if (createdSegment) {
+ // Set this segment as selected to show its tooltip
+ setSelectedSegmentId(createdSegment.id);
+ logger.debug('Created and selected new segment:', createdSegment.id);
+ }
+ }, 100); // Small delay to ensure state is updated
+ }}
+ >
+
+
+
+
+
+ New
+
+
+ {/* Go to start button - play from beginning of cutaway (until next segment) */}
+
{
+ e.stopPropagation();
+
+ if (videoRef.current) {
+ // Find cutaway boundaries (current position is somewhere in the cutaway)
+ const currentTime = clickedTime;
+
+ // Enable continuePastBoundary flag when user explicitly clicks play
+ // This will allow playback to continue even if we're at segment boundary
+ setContinuePastBoundary(true);
+ logger.debug(
+ 'Setting continuePastBoundary=true to allow playback through boundaries'
+ );
+
+ // For start, find the previous segment's end or use video start (0)
+ const sortedSegments = [...clipSegments].sort(
+ (a, b) => a.startTime - b.startTime
+ );
+
+ // Find the previous segment (one that ends before the current time)
+ const previousSegment = [...sortedSegments]
+ .reverse()
+ .find((seg) => seg.endTime < currentTime);
+
+ // Start from either previous segment end or beginning of video
+ // Add a small offset (0.025 second = 25ms) to ensure we're definitely past the segment boundary
+ const startTime = previousSegment ? previousSegment.endTime + 0.025 : 0;
+
+ // For end, find the next segment after the current position
+ // Since we're looking for the boundary of this empty space, we need to find the
+ // segment that starts after our current position
+ const nextSegment = sortedSegments.find(
+ (seg) => seg.startTime > currentTime
+ );
+
+ // Define end boundary (either next segment start or video end)
+ const endTime = nextSegment ? nextSegment.startTime : duration;
+
+ // Create a virtual "segment" for the cutaway area
+ const cutawaySegment: Segment = {
+ id: -999, // Use a unique negative ID to indicate a virtual segment
+ chapterTitle: 'Cutaway',
+ startTime: startTime,
+ endTime: endTime,
+ };
+
+ // Seek to the start of the cutaway (true beginning of this cutaway area)
+ onSeek(startTime);
+ setClickedTime(startTime);
+
+ // IMPORTANT: First reset isPlayingSegment to false to ensure clean state
+ setIsPlayingSegment(false);
+
+ // Then set active segment for boundary checking
+ // We use setTimeout to ensure this happens in the next tick
+ // after the isPlayingSegment value is updated
+ setTimeout(() => {
+ setActiveSegment(cutawaySegment);
+ }, 0);
+
+ // No boundary checking - allow continuous playback
+
+ // Start playing with proper promise handling - use setTimeout to ensure
+ // that our activeSegment setting has had time to take effect
+ setTimeout(() => {
+ if (videoRef.current) {
+ // Now start playback
+ videoRef.current
+ .play()
+ .then(() => {
+ setIsPlayingSegment(true);
+ logger.debug(
+ 'CUTAWAY PLAYBACK STARTED:',
+ formatDetailedTime(startTime),
+ 'to',
+ formatDetailedTime(endTime),
+ previousSegment
+ ? `(after segment ${
+ previousSegment.id
+ }, offset +25ms from ${formatDetailedTime(
+ previousSegment.endTime
+ )})`
+ : '(from video start)',
+ nextSegment
+ ? `(will stop at segment ${nextSegment.id})`
+ : '(will play to end)'
+ );
+ })
+ .catch((err) => {
+ console.error('Error playing cutaway:', err);
+ });
+ }
+ }, 50);
+ }
+ }}
+ >
+
+
+
+ {/* Play/Pause button for empty space - Same as main play/pause button */}
+
{
+ e.stopPropagation();
+
+ if (isPlaying) {
+ // If playing, just pause
+ if (videoRef.current) {
+ videoRef.current.pause();
+ setIsPlayingSegment(false);
+ setContinuePastBoundary(false);
+ }
+ } else {
+ onPlayPause();
+ }
+ }}
+ >
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Segment end adjustment button (always shown) */}
+
{
+ e.stopPropagation();
+
+ // Find the previous segment (one that ends before the current time)
+ const sortedSegments = [...clipSegments].sort(
+ (a, b) => a.startTime - b.startTime
+ );
+ const prevSegment = sortedSegments
+ .filter((seg) => seg.endTime <= clickedTime)
+ .sort((a, b) => b.endTime - a.endTime)[0]; // Get the closest one before
+
+ if (prevSegment) {
+ // Regular case: adjust end of previous segment
+ const updatedSegments = clipSegments.map((seg) => {
+ if (seg.id === prevSegment.id) {
+ return {
+ ...seg,
+ endTime: clickedTime,
+ };
+ }
+ return seg;
+ });
+
+ // Create and dispatch the update event
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: updatedSegments,
+ recordHistory: true,
+ action: 'adjust_previous_end_time',
+ },
+ });
+ document.dispatchEvent(updateEvent);
+ logger.debug(
+ 'Adjusted end of previous segment to:',
+ formatDetailedTime(clickedTime)
+ );
+
+ // Show the previous segment's tooltip
+ setSelectedSegmentId(prevSegment.id);
+ setShowEmptySpaceTooltip(false);
+ } else if (clipSegments.length > 0) {
+ // No previous segment at cursor position, but segments exist elsewhere
+
+ // First, check if we're in a gap between segments - if so, create a new segment for the gap
+ const sortedByStart = [...clipSegments].sort(
+ (a, b) => a.startTime - b.startTime
+ );
+ let inGap = false;
+ let gapStart = 0;
+
+ // Check if we're in a gap between segments
+ for (let i = 0; i < sortedByStart.length - 1; i++) {
+ const currentSegEnd = sortedByStart[i].endTime;
+ const nextSegStart = sortedByStart[i + 1].startTime;
+
+ if (clickedTime > currentSegEnd && clickedTime < nextSegStart) {
+ inGap = true;
+ gapStart = currentSegEnd;
+ break;
+ }
+ }
+
+ if (inGap) {
+ // We're in a gap, create a new segment from gap start to clicked time
+ const newSegment: Segment = {
+ id: Date.now(),
+ chapterTitle: generateChapterName(gapStart, clipSegments),
+ startTime: gapStart,
+ endTime: clickedTime,
+ };
+
+ // Add the new segment to existing segments
+ const updatedSegments = [...clipSegments, newSegment];
+
+ // Create and dispatch the update event
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: updatedSegments,
+ recordHistory: true,
+ action: 'create_segment_in_gap',
+ },
+ });
+ document.dispatchEvent(updateEvent);
+ logger.debug(
+ 'Created new segment in gap from',
+ formatDetailedTime(gapStart),
+ 'to',
+ formatDetailedTime(clickedTime)
+ );
+
+ // Show the new segment's tooltip
+ setSelectedSegmentId(newSegment.id);
+ setShowEmptySpaceTooltip(false);
+ }
+ // Check if we're before all segments and should create a segment from start
+ else if (clickedTime < sortedByStart[0].startTime) {
+ // Create a new segment from start of video to clicked time
+ const newSegment: Segment = {
+ id: Date.now(),
+ chapterTitle: generateChapterName(0, clipSegments),
+ startTime: 0,
+ endTime: clickedTime,
+ };
+
+ // Add the new segment to existing segments
+ const updatedSegments = [...clipSegments, newSegment];
+
+ // Create and dispatch the update event
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: updatedSegments,
+ recordHistory: true,
+ action: 'create_segment_from_start',
+ },
+ });
+ document.dispatchEvent(updateEvent);
+ logger.debug(
+ 'Created new segment from start to:',
+ formatDetailedTime(clickedTime)
+ );
+
+ // Show the new segment's tooltip
+ setSelectedSegmentId(newSegment.id);
+ setShowEmptySpaceTooltip(false);
+ } else {
+ // Not in a gap, check if we can extend the last segment to end of video
+ const lastSegment = [...clipSegments].sort(
+ (a, b) => b.endTime - a.endTime
+ )[0];
+
+ if (lastSegment && lastSegment.endTime < duration) {
+ // Extend the last segment to end of video
+ const updatedSegments = clipSegments.map((seg) => {
+ if (seg.id === lastSegment.id) {
+ return {
+ ...seg,
+ endTime: duration,
+ };
+ }
+ return seg;
+ });
+
+ // Create and dispatch the update event
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: updatedSegments,
+ recordHistory: true,
+ action: 'extend_last_segment',
+ },
+ });
+ document.dispatchEvent(updateEvent);
+ logger.debug('Extended last segment to end of video');
+
+ // Show the last segment's tooltip
+ setSelectedSegmentId(lastSegment.id);
+ setShowEmptySpaceTooltip(false);
+ }
+ }
+ } else if (clickedTime > 0) {
+ // No segments exist; create a new segment from start to clicked time
+ const newSegment: Segment = {
+ id: Date.now(),
+ chapterTitle: generateChapterName(0, clipSegments),
+ startTime: 0,
+ endTime: clickedTime,
+ };
+
+ // Create and dispatch the update event
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: [newSegment],
+ recordHistory: true,
+ action: 'create_segment_from_start',
+ },
+ });
+ document.dispatchEvent(updateEvent);
+ logger.debug(
+ 'Created new segment from start to:',
+ formatDetailedTime(clickedTime)
+ );
+
+ // Show the new segment's tooltip
+ setSelectedSegmentId(newSegment.id);
+ setShowEmptySpaceTooltip(false);
+ }
+ }}
+ >
+
+
+
+ {/* Segment start adjustment button (always shown) */}
+
{
+ e.stopPropagation();
+
+ // Find the next segment (one that starts after the current time)
+ const sortedSegments = [...clipSegments].sort(
+ (a, b) => a.startTime - b.startTime
+ );
+ const nextSegment = sortedSegments
+ .filter((seg) => seg.startTime >= clickedTime)
+ .sort((a, b) => a.startTime - b.startTime)[0]; // Get the closest one after
+
+ if (nextSegment) {
+ // Regular case: adjust start of next segment
+ const updatedSegments = clipSegments.map((seg) => {
+ if (seg.id === nextSegment.id) {
+ return {
+ ...seg,
+ startTime: clickedTime,
+ };
+ }
+ return seg;
+ });
+
+ // Create and dispatch the update event
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: updatedSegments,
+ recordHistory: true,
+ action: 'adjust_next_start_time',
+ },
+ });
+ document.dispatchEvent(updateEvent);
+ logger.debug(
+ 'Adjusted start of next segment to:',
+ formatDetailedTime(clickedTime)
+ );
+
+ // Show the next segment's tooltip
+ setSelectedSegmentId(nextSegment.id);
+ setShowEmptySpaceTooltip(false);
+ } else if (clipSegments.length > 0) {
+ // No next segment at cursor position, but segments exist elsewhere
+
+ // First, check if we're in a gap between segments - if so, create a new segment for the gap
+ const sortedByStart = [...clipSegments].sort(
+ (a, b) => a.startTime - b.startTime
+ );
+ let inGap = false;
+ let gapEnd = 0;
+
+ // Check if we're in a gap between segments
+ for (let i = 0; i < sortedByStart.length - 1; i++) {
+ const currentSegEnd = sortedByStart[i].endTime;
+ const nextSegStart = sortedByStart[i + 1].startTime;
+
+ if (clickedTime > currentSegEnd && clickedTime < nextSegStart) {
+ inGap = true;
+ gapEnd = nextSegStart;
+ break;
+ }
+ }
+
+ if (inGap) {
+ // We're in a gap, create a new segment from clicked time to gap end
+ const newSegment: Segment = {
+ id: Date.now(),
+ chapterTitle: generateChapterName(clickedTime, clipSegments),
+ startTime: clickedTime,
+ endTime: gapEnd,
+ };
+
+ // Add the new segment to existing segments
+ const updatedSegments = [...clipSegments, newSegment];
+
+ // Create and dispatch the update event
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: updatedSegments,
+ recordHistory: true,
+ action: 'create_segment_in_gap',
+ },
+ });
+ document.dispatchEvent(updateEvent);
+ logger.debug(
+ 'Created new segment in gap from',
+ formatDetailedTime(clickedTime),
+ 'to',
+ formatDetailedTime(gapEnd)
+ );
+
+ // Show the new segment's tooltip
+ setSelectedSegmentId(newSegment.id);
+ setShowEmptySpaceTooltip(false);
+ } else {
+ // Check if we're at the start of the video with segments ahead
+ if (clickedTime < sortedByStart[0].startTime) {
+ // Create a new segment from clicked time to first segment start
+ const newSegment: Segment = {
+ id: Date.now(),
+ chapterTitle: generateChapterName(clickedTime, clipSegments),
+ startTime: clickedTime,
+ endTime: sortedByStart[0].startTime,
+ };
+
+ // Add the new segment to existing segments
+ const updatedSegments = [...clipSegments, newSegment];
+
+ // Create and dispatch the update event
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: updatedSegments,
+ recordHistory: true,
+ action: 'create_segment_before_first',
+ },
+ });
+ document.dispatchEvent(updateEvent);
+ logger.debug(
+ 'Created new segment from',
+ formatDetailedTime(clickedTime),
+ 'to first segment'
+ );
+
+ // Show the new segment's tooltip
+ setSelectedSegmentId(newSegment.id);
+ setShowEmptySpaceTooltip(false);
+ }
+ // Check if we're after all segments and should create a segment to the end
+ else if (
+ clickedTime > sortedByStart[sortedByStart.length - 1].endTime
+ ) {
+ // Create a new segment from clicked time to end of video
+ const newSegment: Segment = {
+ id: Date.now(),
+ chapterTitle: generateChapterName(clickedTime, clipSegments),
+ startTime: clickedTime,
+ endTime: duration,
+ };
+
+ // Add the new segment to existing segments
+ const updatedSegments = [...clipSegments, newSegment];
+
+ // Create and dispatch the update event
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: updatedSegments,
+ recordHistory: true,
+ action: 'create_segment_to_end',
+ },
+ });
+ document.dispatchEvent(updateEvent);
+ logger.debug(
+ 'Created new segment from',
+ formatDetailedTime(clickedTime),
+ 'to end'
+ );
+
+ // Show the new segment's tooltip
+ setSelectedSegmentId(newSegment.id);
+ setShowEmptySpaceTooltip(false);
+ } else {
+ // Not in a gap, check if we can extend the first segment to start of video
+ const firstSegment = sortedByStart[0];
+
+ if (firstSegment && firstSegment.startTime > 0) {
+ // Extend the first segment to start of video
+ const updatedSegments = clipSegments.map((seg) => {
+ if (seg.id === firstSegment.id) {
+ return {
+ ...seg,
+ startTime: 0,
+ };
+ }
+ return seg;
+ });
+
+ // Create and dispatch the update event
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: updatedSegments,
+ recordHistory: true,
+ action: 'extend_first_segment',
+ },
+ });
+ document.dispatchEvent(updateEvent);
+ logger.debug('Extended first segment to start of video');
+
+ // Show the first segment's tooltip
+ setSelectedSegmentId(firstSegment.id);
+ setShowEmptySpaceTooltip(false);
+ }
+ }
+ }
+ } else if (clickedTime < duration) {
+ // No segments exist; create a new segment from clicked time to end
+ const newSegment: Segment = {
+ id: Date.now(),
+ chapterTitle: generateChapterName(clickedTime, clipSegments),
+ startTime: clickedTime,
+ endTime: duration,
+ };
+
+ // Create and dispatch the update event
+ const updateEvent = new CustomEvent('update-segments', {
+ detail: {
+ segments: [newSegment],
+ recordHistory: true,
+ action: 'create_segment_to_end',
+ },
+ });
+ document.dispatchEvent(updateEvent);
+ logger.debug(
+ 'Created new segment from',
+ formatDetailedTime(clickedTime),
+ 'to end'
+ );
+
+ // Show the new segment's tooltip
+ setSelectedSegmentId(newSegment.id);
+ setShowEmptySpaceTooltip(false);
+ }
+ }}
+ >
+
+
+
+
+ )}
+
+
+
+ {/* Precise Time Navigation & Zoom Controls */}
+
+ {/* Precise Time Input */}
+
+
Go to Time:
+
{
+ if (e.key === 'Enter') {
+ const input = e.currentTarget.value;
+ try {
+ // Parse time format like "00:30:15.250" or "30:15.250" or "30:15"
+ const parts = input.split(':');
+ let hours = 0,
+ minutes = 0,
+ seconds = 0,
+ milliseconds = 0;
+
+ if (parts.length === 3) {
+ // Format: HH:MM:SS.ms
+ hours = parseInt(parts[0]);
+ minutes = parseInt(parts[1]);
+ const secParts = parts[2].split('.');
+ seconds = parseInt(secParts[0]);
+ if (secParts.length > 1)
+ milliseconds = parseInt(secParts[1].padEnd(3, '0').substring(0, 3));
+ } else if (parts.length === 2) {
+ // Format: MM:SS.ms
+ minutes = parseInt(parts[0]);
+ const secParts = parts[1].split('.');
+ seconds = parseInt(secParts[0]);
+ if (secParts.length > 1)
+ milliseconds = parseInt(secParts[1].padEnd(3, '0').substring(0, 3));
+ }
+
+ const totalSeconds = hours * 3600 + minutes * 60 + seconds + milliseconds / 1000;
+ if (!isNaN(totalSeconds) && totalSeconds >= 0 && totalSeconds <= duration) {
+ onSeek(totalSeconds);
+
+ // Create a helper function to show tooltip that uses the same logic as the millisecond buttons
+ const showTooltipAtTime = (timeInSeconds: number) => {
+ // Find the segment at the given time using improved matching
+ const segmentAtTime = clipSegments.find((seg) => {
+ const isWithinSegment =
+ timeInSeconds >= seg.startTime && timeInSeconds <= seg.endTime;
+ const isAtExactStart = Math.abs(timeInSeconds - seg.startTime) < 0.001; // Within 1ms of start
+ const isAtExactEnd = Math.abs(timeInSeconds - seg.endTime) < 0.001; // Within 1ms of end
+ return isWithinSegment || isAtExactStart || isAtExactEnd;
+ });
+
+ // Calculate position for tooltip
+ if (timelineRef.current && scrollContainerRef.current) {
+ const rect = timelineRef.current.getBoundingClientRect();
+
+ // Handle zoomed timeline by accounting for scroll position
+ let xPos;
+
+ if (zoomLevel > 1) {
+ // For zoomed timeline, calculate position based on visible area
+ const visibleTimelineLeft =
+ rect.left - scrollContainerRef.current.scrollLeft;
+ const markerVisibleX =
+ visibleTimelineLeft + (timeInSeconds / duration) * rect.width;
+ xPos = markerVisibleX;
+ } else {
+ // For non-zoomed timeline, use the simple calculation
+ const positionPercent = timeInSeconds / duration;
+ xPos = rect.left + rect.width * positionPercent;
+ }
+
+ setTooltipPosition({
+ x: xPos,
+ y: rect.top - 10,
+ });
+ setClickedTime(timeInSeconds);
+
+ if (segmentAtTime) {
+ // Show segment tooltip
+ setSelectedSegmentId(segmentAtTime.id);
+ setShowEmptySpaceTooltip(false);
+ } else {
+ // Show empty space tooltip
+ setSelectedSegmentId(null);
+ setShowEmptySpaceTooltip(true);
+ }
+ }
+ };
+
+ // Show tooltip after a slight delay to ensure UI updates
+ setTimeout(() => showTooltipAtTime(totalSeconds), 10);
+ }
+ } catch (error) {
+ console.error('Invalid time format:', error);
+ }
+ }
+ }}
+ />
+
+ {/* Helper function to show tooltip at current position */}
+ {/* This is defined within the component to access state variables and functions */}
+
+ {(() => {
+ // Helper function to show the appropriate tooltip at the current time position
+ const showTooltipAtCurrentTime = () => {
+ // Find the segment at the current time (after seeking) - using improved matching for better precision
+ const segmentAtCurrentTime = clipSegments.find((seg) => {
+ const isWithinSegment = currentTime >= seg.startTime && currentTime <= seg.endTime;
+ const isAtExactStart = Math.abs(currentTime - seg.startTime) < 0.001; // Within 1ms of start
+ const isAtExactEnd = Math.abs(currentTime - seg.endTime) < 0.001; // Within 1ms of end
+ return isWithinSegment || isAtExactStart || isAtExactEnd;
+ });
+
+ // Calculate position for tooltip (above the timeline where the marker is)
+ if (timelineRef.current && scrollContainerRef.current) {
+ const rect = timelineRef.current.getBoundingClientRect();
+
+ // Handle zoomed timeline by accounting for scroll position
+ let xPos;
+
+ if (zoomLevel > 1) {
+ // For zoomed timeline, calculate position based on visible area
+ const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft;
+ const markerVisibleX =
+ visibleTimelineLeft + (currentTime / duration) * rect.width;
+ xPos = markerVisibleX;
+ } else {
+ // For non-zoomed timeline, use the simple calculation
+ const positionPercent = currentTime / duration;
+ xPos = rect.left + rect.width * positionPercent;
+ }
+
+ setTooltipPosition({
+ x: xPos,
+ y: rect.top - 10,
+ });
+ setClickedTime(currentTime);
+
+ if (segmentAtCurrentTime) {
+ // Show segment tooltip
+ setSelectedSegmentId(segmentAtCurrentTime.id);
+ setShowEmptySpaceTooltip(false);
+ } else {
+ // Calculate available space for new segment before showing tooltip
+ const availableSpace = calculateAvailableSpace(currentTime);
+ setAvailableSegmentDuration(availableSpace);
+
+ // Only show tooltip if there's enough space for a minimal segment
+ if (availableSpace >= 0.5) {
+ // Show empty space tooltip
+ setSelectedSegmentId(null);
+ setShowEmptySpaceTooltip(true);
+ } else {
+ // Not enough space, don't show any tooltip
+ setSelectedSegmentId(null);
+ setShowEmptySpaceTooltip(false);
+ }
+ }
+ }
+ };
+
+ return (
+ <>
+ {
+ // Move back 10ms
+ onSeek(currentTime - 0.01);
+ // Show appropriate tooltip
+ setTimeout(showTooltipAtCurrentTime, 10); // Short delay to ensure time is updated
+ }}
+ data-tooltip="Move back 10ms"
+ >
+ -10ms
+
+ {
+ // Move back 1ms
+ onSeek(currentTime - 0.001);
+ // Show appropriate tooltip
+ setTimeout(showTooltipAtCurrentTime, 10);
+ }}
+ data-tooltip="Move back 1ms"
+ >
+ -1ms
+
+ {
+ // Move forward 1ms
+ onSeek(currentTime + 0.001);
+ // Show appropriate tooltip
+ setTimeout(showTooltipAtCurrentTime, 10);
+ }}
+ data-tooltip="Move forward 1ms"
+ >
+ +1ms
+
+ {
+ // Move forward 10ms
+ onSeek(currentTime + 0.01);
+ // Show appropriate tooltip
+ setTimeout(showTooltipAtCurrentTime, 10);
+ }}
+ data-tooltip="Move forward 10ms"
+ >
+ +10ms
+
+ >
+ );
+ })()}
+
+
+
+ {/* Zoom Dropdown Control and Save Buttons */}
+
+
+
setIsZoomDropdownOpen(!isZoomDropdownOpen)}
+ >
+ Zoom {zoomLevel}x
+
+
+
+
+
+ {isZoomDropdownOpen && (
+
+ {[1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096].map((level) => (
+
{
+ onZoomChange(level);
+ setIsZoomDropdownOpen(false);
+ }}
+ >
+ {zoomLevel === level && (
+
+
+
+ )}
+ Zoom {level}x
+
+ ))}
+
+ )}
+
+
+ {/* Auto saved time */}
+
+ {isAutoSaving ? (
+ <>
+
+ Auto saving...
+ >
+ ) : lastAutoSaveTime ? (
+ `Auto saved: ${lastAutoSaveTime}`
+ ) : (
+ 'Not saved yet'
+ )}
+
+
+ {/* Save Chapters Button */}
+
+ setShowSaveChaptersModal(true)}
+ className="save-chapters-button"
+ data-tooltip={clipSegments.length === 0
+ ? "Clear all chapters"
+ : "Save chapters"}
+ >
+ {clipSegments.length === 0
+ ? 'Clear Chapters'
+ : 'Save Chapters'}
+
+
+
+ {/* Save Confirmation Modal */}
+
setShowSaveChaptersModal(false)}
+ title="Save Chapters"
+ actions={
+ <>
+ setShowSaveChaptersModal(false)}
+ >
+ Cancel
+
+
+ {clipSegments.length === 0
+ ? 'Clear Chapters'
+ : 'Save Chapters'}
+
+ >
+ }
+ >
+
+ {clipSegments.length === 0
+ ? "Are you sure you want to clear all chapters? This will remove all existing chapters from the database."
+ : `Are you sure you want to save the chapters? This will save ${clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length} chapters to the database.`}
+
+
+
+ {/* Processing Modal */}
+
{}} title="Processing Video">
+
+ Please wait while your video is being processed...
+
+
+ {/* Success Modal */}
+
{
+ setShowSuccessModal(false);
+ }}
+ title="Video Edited Successfully"
+ >
+
+ {/*
+ {successMessage || "Processing completed successfully!"}
+
*/}
+
+
+ Your chapters have been saved successfully!
+
+ Click here to navigate to the media page
+
+ {' '}or close this window to continue editing the chapters.
+
+
+
+
+ {/* Error Modal */}
+
setShowErrorModal(false)}
+ title="Video Processing Error"
+ >
+
+
+
+
+
+
+
+
+
{errorMessage}
+
+
+ setShowErrorModal(false)}
+ className="modal-choice-button centered-choice"
+ >
+
+
+
+
+ Close
+
+
+
+
+ {/* Dropdown was moved inside the container element */}
+
+
+
+ {/* Mobile Uninitialized Overlay - Show only when on mobile and video hasn't been played yet */}
+ {isIOSUninitialized && (
+
+
+
Please play the video first to enable timeline controls
+
+
+
+ )}
+
+ );
+};
+
+export default TimelineControls;
diff --git a/frontend-tools/chapters-editor/client/src/components/VideoPlayer.tsx b/frontend-tools/chapters-editor/client/src/components/VideoPlayer.tsx
new file mode 100644
index 00000000..1fc874d2
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/components/VideoPlayer.tsx
@@ -0,0 +1,492 @@
+import React, { useRef, useEffect, useState } from 'react';
+import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
+import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
+import logger from '../lib/logger';
+import '../styles/VideoPlayer.css';
+
+interface VideoPlayerProps {
+ videoRef: React.RefObject;
+ currentTime: number;
+ duration: number;
+ isPlaying: boolean;
+ isMuted?: boolean;
+ onPlayPause: () => void;
+ onSeek: (time: number) => void;
+ onToggleMute?: () => void;
+}
+
+const VideoPlayer: React.FC = ({
+ videoRef,
+ currentTime,
+ duration,
+ isPlaying,
+ isMuted = false,
+ onPlayPause,
+ onSeek,
+ onToggleMute,
+}) => {
+ const progressRef = useRef(null);
+ const [isIOS, setIsIOS] = useState(false);
+ const [hasInitialized, setHasInitialized] = useState(false);
+ const [lastPosition, setLastPosition] = useState(null);
+ const [isDraggingProgress, setIsDraggingProgress] = useState(false);
+ const isDraggingProgressRef = useRef(false);
+ const [tooltipPosition, setTooltipPosition] = useState({
+ x: 0,
+ });
+ const [tooltipTime, setTooltipTime] = useState(0);
+
+ const sampleVideoUrl =
+ (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp4';
+
+ // Check if the media is an audio file
+ const isAudioFile = sampleVideoUrl.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
+
+ // Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
+ const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
+ const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
+ const posterImage = isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined);
+
+ // Detect iOS device and Safari browser
+ useEffect(() => {
+ const checkIOS = () => {
+ const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
+ return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
+ };
+
+ const checkSafari = () => {
+ const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
+ return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
+ };
+
+ setIsIOS(checkIOS());
+
+ // Store Safari detection globally for other components
+ if (typeof window !== 'undefined') {
+ (window as any).isSafari = checkSafari();
+ }
+
+ // Check if video was previously initialized
+ if (typeof window !== 'undefined') {
+ const wasInitialized = localStorage.getItem('video_initialized') === 'true';
+ setHasInitialized(wasInitialized);
+ }
+ }, []);
+
+ // Update initialized state when video plays
+ useEffect(() => {
+ if (isPlaying && !hasInitialized) {
+ setHasInitialized(true);
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('video_initialized', 'true');
+ }
+ }
+ }, [isPlaying, hasInitialized]);
+
+ // Add iOS-specific attributes to prevent fullscreen playback
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ // These attributes need to be set directly on the DOM element
+ // for iOS Safari to respect inline playback
+ video.setAttribute('playsinline', 'true');
+ video.setAttribute('webkit-playsinline', 'true');
+ video.setAttribute('x-webkit-airplay', 'allow');
+
+ // Store the last known good position for iOS
+ const handleTimeUpdate = () => {
+ if (!isDraggingProgressRef.current) {
+ setLastPosition(video.currentTime);
+ if (typeof window !== 'undefined') {
+ window.lastSeekedPosition = video.currentTime;
+ }
+ }
+ };
+
+ // Handle iOS-specific play/pause state
+ const handlePlay = () => {
+ logger.debug('Video play event fired');
+ if (isIOS) {
+ setHasInitialized(true);
+ localStorage.setItem('video_initialized', 'true');
+ }
+ };
+
+ const handlePause = () => {
+ logger.debug('Video pause event fired');
+ };
+
+ video.addEventListener('timeupdate', handleTimeUpdate);
+ video.addEventListener('play', handlePlay);
+ video.addEventListener('pause', handlePause);
+
+ return () => {
+ video.removeEventListener('timeupdate', handleTimeUpdate);
+ video.removeEventListener('play', handlePlay);
+ video.removeEventListener('pause', handlePause);
+ };
+ }, [videoRef, isIOS, isDraggingProgressRef]);
+
+ // Save current time to lastPosition when it changes (from external seeking)
+ useEffect(() => {
+ setLastPosition(currentTime);
+ }, [currentTime]);
+
+ // Jump 10 seconds forward
+ const handleForward = () => {
+ const newTime = Math.min(currentTime + 10, duration);
+ onSeek(newTime);
+ setLastPosition(newTime);
+ };
+
+ // Jump 10 seconds backward
+ const handleBackward = () => {
+ const newTime = Math.max(currentTime - 10, 0);
+ onSeek(newTime);
+ setLastPosition(newTime);
+ };
+
+ // Calculate progress percentage
+ const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
+
+ // Handle start of progress bar dragging
+ const handleProgressDragStart = (e: React.MouseEvent) => {
+ e.preventDefault();
+
+ setIsDraggingProgress(true);
+ isDraggingProgressRef.current = true;
+
+ // Get initial position
+ handleProgressDrag(e);
+
+ // Set up document-level event listeners for mouse movement and release
+ const handleMouseMove = (moveEvent: MouseEvent) => {
+ if (isDraggingProgressRef.current) {
+ handleProgressDrag(moveEvent);
+ }
+ };
+
+ const handleMouseUp = () => {
+ setIsDraggingProgress(false);
+ isDraggingProgressRef.current = false;
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ };
+
+ // Handle progress dragging for both mouse and touch events
+ const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
+ if (!progressRef.current) return;
+
+ const rect = progressRef.current.getBoundingClientRect();
+ const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+ const seekTime = duration * clickPosition;
+
+ // Update tooltip position and time
+ setTooltipPosition({
+ x: e.clientX,
+ });
+ setTooltipTime(seekTime);
+
+ // Store position locally for iOS Safari - critical for timeline seeking
+ setLastPosition(seekTime);
+
+ // Also store globally for integration with other components
+ if (typeof window !== 'undefined') {
+ (window as any).lastSeekedPosition = seekTime;
+ }
+
+ onSeek(seekTime);
+ };
+
+ // Handle touch events for progress bar
+ const handleProgressTouchStart = (e: React.TouchEvent) => {
+ if (!progressRef.current || !e.touches[0]) return;
+ e.preventDefault();
+
+ setIsDraggingProgress(true);
+ isDraggingProgressRef.current = true;
+
+ // Get initial position using touch
+ handleProgressTouchMove(e);
+
+ // Set up document-level event listeners for touch movement and release
+ const handleTouchMove = (moveEvent: TouchEvent) => {
+ if (isDraggingProgressRef.current) {
+ handleProgressTouchMove(moveEvent);
+ }
+ };
+
+ const handleTouchEnd = () => {
+ setIsDraggingProgress(false);
+ isDraggingProgressRef.current = false;
+ document.removeEventListener('touchmove', handleTouchMove);
+ document.removeEventListener('touchend', handleTouchEnd);
+ document.removeEventListener('touchcancel', handleTouchEnd);
+ };
+
+ document.addEventListener('touchmove', handleTouchMove, {
+ passive: false,
+ });
+ document.addEventListener('touchend', handleTouchEnd);
+ document.addEventListener('touchcancel', handleTouchEnd);
+ };
+
+ // Handle touch dragging on progress bar
+ const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
+ if (!progressRef.current) return;
+
+ // Get the touch coordinates
+ const touch = 'touches' in e ? e.touches[0] : null;
+ if (!touch) return;
+
+ e.preventDefault(); // Prevent scrolling while dragging
+
+ const rect = progressRef.current.getBoundingClientRect();
+ const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
+ const seekTime = duration * touchPosition;
+
+ // Update tooltip position and time
+ setTooltipPosition({
+ x: touch.clientX,
+ });
+ setTooltipTime(seekTime);
+
+ // Store position for iOS Safari
+ setLastPosition(seekTime);
+
+ // Also store globally for integration with other components
+ if (typeof window !== 'undefined') {
+ (window as any).lastSeekedPosition = seekTime;
+ }
+
+ onSeek(seekTime);
+ };
+
+ // Handle click on progress bar (for non-drag interactions)
+ const handleProgressClick = (e: React.MouseEvent) => {
+ // If we're already dragging, don't handle the click
+ if (isDraggingProgress) return;
+
+ if (progressRef.current) {
+ const rect = progressRef.current.getBoundingClientRect();
+ const clickPosition = (e.clientX - rect.left) / rect.width;
+ const seekTime = duration * clickPosition;
+
+ // Store position locally for iOS Safari - critical for timeline seeking
+ setLastPosition(seekTime);
+
+ // Also store globally for integration with other components
+ if (typeof window !== 'undefined') {
+ (window as any).lastSeekedPosition = seekTime;
+ }
+
+ onSeek(seekTime);
+ }
+ };
+
+ // Handle toggling fullscreen
+ const handleFullscreen = () => {
+ if (videoRef.current) {
+ if (document.fullscreenElement) {
+ document.exitFullscreen();
+ } else {
+ videoRef.current.requestFullscreen();
+ }
+ }
+ };
+
+ // Handle click on video to play/pause
+ const handleVideoClick = () => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ // If the video is paused, we want to play it
+ if (video.paused) {
+ // For iOS Safari: Before playing, explicitly seek to the remembered position
+ if (isIOS && lastPosition !== null && lastPosition > 0) {
+ logger.debug('iOS: Explicitly setting position before play:', lastPosition);
+
+ // First, seek to the position
+ video.currentTime = lastPosition;
+
+ // Use a small timeout to ensure seeking is complete before play
+ setTimeout(() => {
+ if (videoRef.current) {
+ // Try to play with proper promise handling
+ videoRef.current
+ .play()
+ .then(() => {
+ logger.debug(
+ 'iOS: Play started successfully at position:',
+ videoRef.current?.currentTime
+ );
+ onPlayPause(); // Update parent state after successful play
+ })
+ .catch((err) => {
+ console.error('iOS: Error playing video:', err);
+ });
+ }
+ }, 50);
+ } else {
+ // Normal play (non-iOS or no remembered position)
+ video
+ .play()
+ .then(() => {
+ logger.debug('Normal: Play started successfully');
+ onPlayPause(); // Update parent state after successful play
+ })
+ .catch((err) => {
+ console.error('Error playing video:', err);
+ });
+ }
+ } else {
+ // If playing, pause and update state
+ video.pause();
+ onPlayPause();
+ }
+ };
+
+ return (
+
+
+
+ {/* Safari fallback for audio files */}
+
+
+ Your browser doesn't support HTML5 video or audio.
+
+
+ {/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}
+ {isIOS && !hasInitialized && !isPlaying && (
+
+
Tap Play to initialize video controls
+
+ )}
+
+ {/* Play/Pause Indicator (shows based on current state) */}
+
+
+ {/* Video Controls Overlay */}
+
+ {/* Time and Duration */}
+
+ {formatTime(currentTime)}
+ / {formatTime(duration)}
+
+
+ {/* Progress Bar with enhanced dragging */}
+
+
+
+
+ {/* Floating time tooltip when dragging */}
+ {isDraggingProgress && (
+
+ {formatDetailedTime(tooltipTime)}
+
+ )}
+
+
+ {/* Controls - Mute and Fullscreen buttons */}
+
+ {/* Mute/Unmute Button */}
+ {onToggleMute && (
+
+ {isMuted ? (
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+
+ )}
+
+ {/* Fullscreen Button */}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default VideoPlayer;
diff --git a/frontend-tools/chapters-editor/client/src/hooks/useVideoChapters.tsx b/frontend-tools/chapters-editor/client/src/hooks/useVideoChapters.tsx
new file mode 100644
index 00000000..eedd186f
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/hooks/useVideoChapters.tsx
@@ -0,0 +1,1142 @@
+import { useState, useRef, useEffect } from 'react';
+import { formatDetailedTime } from '@/lib/timeUtils';
+import logger from '@/lib/logger';
+import type { Segment } from '@/components/ClipSegments';
+
+// Represents a state of the editor for undo/redo
+interface EditorState {
+ trimStart: number;
+ trimEnd: number;
+ splitPoints: number[];
+ clipSegments: Segment[];
+ action?: string;
+}
+
+const useVideoChapters = () => {
+ // Helper function to generate proper chapter name based on chronological position
+ const generateChapterName = (newSegmentStartTime: number, existingSegments: Segment[]): string => {
+ // Create a temporary array with all segments including the new one
+ const allSegments = [...existingSegments, { startTime: newSegmentStartTime } as Segment];
+ // Sort by start time to find chronological position
+ const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
+ // Find the index of our new segment
+ const chapterIndex = sortedSegments.findIndex(seg => seg.startTime === newSegmentStartTime);
+ return `Chapter ${chapterIndex + 1}`;
+ };
+
+ // Helper function to renumber all segments in chronological order
+ const renumberAllSegments = (segments: Segment[]): Segment[] => {
+ // Sort segments by start time
+ const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
+
+ // Renumber each segment based on its chronological position
+ return sortedSegments.map((segment, index) => ({
+ ...segment,
+ chapterTitle: `Chapter ${index + 1}`
+ }));
+ };
+
+ // Helper function to parse time string (HH:MM:SS.mmm) to seconds
+ const parseTimeToSeconds = (timeString: string): number => {
+ const parts = timeString.split(':');
+ if (parts.length !== 3) return 0;
+
+ const hours = parseInt(parts[0], 10) || 0;
+ const minutes = parseInt(parts[1], 10) || 0;
+ const seconds = parseFloat(parts[2]) || 0;
+
+ return hours * 3600 + minutes * 60 + seconds;
+ };
+
+ // Video element reference and state
+ const videoRef = useRef(null);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [isMuted, setIsMuted] = useState(false);
+
+ // Timeline state
+ const [trimStart, setTrimStart] = useState(0);
+ const [trimEnd, setTrimEnd] = useState(0);
+ const [splitPoints, setSplitPoints] = useState([]);
+ const [zoomLevel, setZoomLevel] = useState(1); // Start with 1x zoom level
+
+ // Clip segments state
+ const [clipSegments, setClipSegments] = useState([]);
+
+ // Selected segment state for chapter editing
+ const [selectedSegmentId, setSelectedSegmentId] = useState(null);
+
+ // History state for undo/redo
+ const [history, setHistory] = useState([]);
+ const [historyPosition, setHistoryPosition] = useState(-1);
+
+ // Track unsaved changes
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+
+ // State for playing segments
+ const [isPlayingSegments, setIsPlayingSegments] = useState(false);
+ const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0);
+
+ // Monitor for history changes
+ useEffect(() => {
+ if (history.length > 0) {
+ // For debugging - moved to console.debug
+ if (process.env.NODE_ENV === 'development') {
+ console.debug(`History state updated: ${history.length} entries, position: ${historyPosition}`);
+ // Log actions in history to help debug undo/redo
+ const actions = history.map(
+ (state, idx) => `${idx}: ${state.action || 'unknown'} (segments: ${state.clipSegments.length})`
+ );
+ console.debug('History actions:', actions);
+ }
+
+ // If there's at least one history entry and it wasn't a save operation, mark as having unsaved changes
+ const lastAction = history[historyPosition]?.action || '';
+ if (lastAction !== 'save' && lastAction !== 'save_copy' && lastAction !== 'save_segments') {
+ setHasUnsavedChanges(true);
+ }
+ }
+ }, [history, historyPosition]);
+
+ // Detect Safari browser
+ const isSafari = () => {
+ const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
+ const isSafariBrowser = /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
+ if (isSafariBrowser) {
+ logger.debug('Safari browser detected, enabling audio support fallbacks');
+ }
+ return isSafariBrowser;
+ };
+
+ // Initialize video event listeners
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ const handleLoadedMetadata = () => {
+ logger.debug('Video loadedmetadata event fired, duration:', video.duration);
+ setDuration(video.duration);
+ setTrimEnd(video.duration);
+
+ // Generate placeholders and create initial segments
+ const initializeEditor = async () => {
+ let initialSegments: Segment[] = [];
+
+ // Check if we have existing chapters from the backend
+ const existingChapters =
+ (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) ||
+ [];
+
+ if (existingChapters.length > 0) {
+ // Create segments from existing chapters
+ for (let i = 0; i < existingChapters.length; i++) {
+ const chapter = existingChapters[i];
+
+ // Parse time strings to seconds
+ const startTime = parseTimeToSeconds(chapter.startTime);
+ const endTime = parseTimeToSeconds(chapter.endTime);
+
+ const segment: Segment = {
+ id: i + 1,
+ chapterTitle: chapter.chapterTitle,
+ startTime: startTime,
+ endTime: endTime,
+ };
+
+ initialSegments.push(segment);
+ }
+ } else {
+ // Create a default segment that spans the entire video on first load
+ const initialSegment: Segment = {
+ id: 1,
+ chapterTitle: '',
+ startTime: 0,
+ endTime: video.duration,
+ };
+
+ initialSegments = [initialSegment];
+ }
+
+ // Initialize history state with the segments
+ const initialState: EditorState = {
+ trimStart: 0,
+ trimEnd: video.duration,
+ splitPoints: [],
+ clipSegments: initialSegments,
+ };
+
+ setHistory([initialState]);
+ setHistoryPosition(0);
+ setClipSegments(initialSegments);
+ logger.debug('Editor initialized with segments:', initialSegments.length);
+ };
+
+ initializeEditor();
+ };
+
+ // Safari-specific fallback for audio files
+ const handleCanPlay = () => {
+ logger.debug('Video canplay event fired');
+ // If loadedmetadata hasn't fired yet but we have duration, trigger initialization
+ if (video.duration && duration === 0) {
+ logger.debug('Safari fallback: Using canplay event to initialize');
+ handleLoadedMetadata();
+ }
+ };
+
+ // Additional Safari fallback for audio files
+ const handleLoadedData = () => {
+ logger.debug('Video loadeddata event fired');
+ // If we still don't have duration, try again
+ if (video.duration && duration === 0) {
+ logger.debug('Safari fallback: Using loadeddata event to initialize');
+ handleLoadedMetadata();
+ }
+ };
+
+ const handleTimeUpdate = () => {
+ setCurrentTime(video.currentTime);
+ };
+
+ const handlePlay = () => {
+ setIsPlaying(true);
+ setVideoInitialized(true);
+ };
+
+ const handlePause = () => {
+ setIsPlaying(false);
+ };
+
+ const handleEnded = () => {
+ setIsPlaying(false);
+ video.currentTime = trimStart;
+ };
+
+ // Add event listeners
+ video.addEventListener('loadedmetadata', handleLoadedMetadata);
+ video.addEventListener('timeupdate', handleTimeUpdate);
+ video.addEventListener('play', handlePlay);
+ video.addEventListener('pause', handlePause);
+ video.addEventListener('ended', handleEnded);
+
+ // Safari-specific fallback event listeners for audio files
+ if (isSafari()) {
+ logger.debug('Adding Safari-specific event listeners for audio support');
+ video.addEventListener('canplay', handleCanPlay);
+ video.addEventListener('loadeddata', handleLoadedData);
+
+ // Additional timeout fallback for Safari audio files
+ const safariTimeout = setTimeout(() => {
+ if (video.duration && duration === 0) {
+ logger.debug('Safari timeout fallback: Force initializing editor');
+ handleLoadedMetadata();
+ }
+ }, 1000);
+
+ return () => {
+ // Remove event listeners
+ video.removeEventListener('loadedmetadata', handleLoadedMetadata);
+ video.removeEventListener('timeupdate', handleTimeUpdate);
+ video.removeEventListener('play', handlePlay);
+ video.removeEventListener('pause', handlePause);
+ video.removeEventListener('ended', handleEnded);
+ video.removeEventListener('canplay', handleCanPlay);
+ video.removeEventListener('loadeddata', handleLoadedData);
+ clearTimeout(safariTimeout);
+ };
+ }
+
+ return () => {
+ // Remove event listeners
+ video.removeEventListener('loadedmetadata', handleLoadedMetadata);
+ video.removeEventListener('timeupdate', handleTimeUpdate);
+ video.removeEventListener('play', handlePlay);
+ video.removeEventListener('pause', handlePause);
+ video.removeEventListener('ended', handleEnded);
+ };
+ }, []);
+
+ // Safari auto-initialization on user interaction
+ useEffect(() => {
+ if (isSafari() && videoRef.current) {
+ const video = videoRef.current;
+
+ const initializeSafariOnInteraction = () => {
+ // Try to load video metadata by attempting to play and immediately pause
+ const attemptInitialization = async () => {
+ try {
+ logger.debug('Safari: Attempting auto-initialization on user interaction');
+
+ // Briefly play to trigger metadata loading, then pause
+ await video.play();
+ video.pause();
+
+ // Check if we now have duration and initialize if needed
+ if (video.duration > 0 && clipSegments.length === 0) {
+ logger.debug('Safari: Successfully initialized metadata, creating default segment');
+
+ const defaultSegment: Segment = {
+ id: 1,
+ chapterTitle: '',
+ startTime: 0,
+ endTime: video.duration,
+ };
+
+ setDuration(video.duration);
+ setTrimEnd(video.duration);
+ setClipSegments([defaultSegment]);
+
+ const initialState: EditorState = {
+ trimStart: 0,
+ trimEnd: video.duration,
+ splitPoints: [],
+ clipSegments: [defaultSegment],
+ };
+
+ setHistory([initialState]);
+ setHistoryPosition(0);
+ }
+ } catch (error) {
+ logger.debug('Safari: Auto-initialization failed, will retry on next interaction:', error);
+ }
+ };
+
+ attemptInitialization();
+ };
+
+ // Listen for any user interaction with video controls
+ const handleUserInteraction = () => {
+ if (clipSegments.length === 0 && video.duration === 0) {
+ initializeSafariOnInteraction();
+ }
+ };
+
+ // Add listeners for various user interactions
+ document.addEventListener('click', handleUserInteraction);
+ document.addEventListener('keydown', handleUserInteraction);
+
+ return () => {
+ document.removeEventListener('click', handleUserInteraction);
+ document.removeEventListener('keydown', handleUserInteraction);
+ };
+ }
+ }, [clipSegments.length]);
+
+ // Safari initialization helper
+ const initializeSafariIfNeeded = async () => {
+ if (isSafari() && videoRef.current && duration === 0) {
+ const video = videoRef.current;
+ try {
+ logger.debug('Safari: Initializing on user interaction');
+ // This play/pause will trigger metadata loading in Safari
+ await video.play();
+ video.pause();
+
+ // The metadata events should fire now and initialize segments
+ return true;
+ } catch (error) {
+ logger.debug('Safari: Initialization attempt failed:', error);
+ return false;
+ }
+ }
+ return false;
+ };
+
+ // Play/pause video
+ const playPauseVideo = () => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ if (isPlaying) {
+ video.pause();
+ } else {
+ // Safari: Try to initialize if needed before playing
+ if (isSafari() && duration === 0) {
+ initializeSafariIfNeeded().then(() => {
+ // After initialization, try to play again
+ setTimeout(() => {
+ if (video && !isPlaying) {
+ video.play().catch((err) => {
+ console.error('Error playing after Safari initialization:', err);
+ });
+ }
+ }, 100);
+ });
+ return;
+ }
+
+ // iOS Safari fix: Use the last seeked position if available
+ if (!isPlaying && typeof window !== 'undefined' && window.lastSeekedPosition > 0) {
+ // Only apply this if the video is not at the same position already
+ // This avoids unnecessary seeking which might cause playback issues
+ if (Math.abs(video.currentTime - window.lastSeekedPosition) > 0.1) {
+ video.currentTime = window.lastSeekedPosition;
+ }
+ }
+ // If at the end of the trim range, reset to the beginning
+ else if (video.currentTime >= trimEnd) {
+ video.currentTime = trimStart;
+ }
+
+ video
+ .play()
+ .then(() => {
+ // Play started successfully
+ // Reset the last seeked position after successfully starting playback
+ if (typeof window !== 'undefined') {
+ window.lastSeekedPosition = 0;
+ }
+ })
+ .catch((err) => {
+ console.error('Error starting playback:', err);
+ setIsPlaying(false); // Reset state if play failed
+ });
+ }
+ };
+
+ // Seek to a specific time
+ const seekVideo = (time: number) => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ // Safari: Try to initialize if needed before seeking
+ if (isSafari() && duration === 0) {
+ initializeSafariIfNeeded().then(() => {
+ // After initialization, try to seek again
+ setTimeout(() => {
+ if (video) {
+ video.currentTime = time;
+ setCurrentTime(time);
+ }
+ }, 100);
+ });
+ return;
+ }
+
+ // Track if the video was playing before seeking
+ const wasPlaying = !video.paused;
+
+ // Update the video position
+ video.currentTime = time;
+ setCurrentTime(time);
+
+ // Store the position in a global state accessible to iOS Safari
+ // This ensures when play is pressed later, it remembers the position
+ if (typeof window !== 'undefined') {
+ window.lastSeekedPosition = time;
+ }
+
+ // Resume playback if it was playing before
+ if (wasPlaying) {
+ // Play immediately without delay
+ video
+ .play()
+ .then(() => {
+ setIsPlaying(true); // Update state to reflect we're playing
+ })
+ .catch((err) => {
+ console.error('Error resuming playback:', err);
+ setIsPlaying(false);
+ });
+ }
+ };
+
+ // Save the current state to history with a debounce buffer
+ // This helps prevent multiple rapid saves for small adjustments
+ const saveState = (action?: string) => {
+ // Deep clone to ensure state is captured correctly
+ const newState: EditorState = {
+ trimStart,
+ trimEnd,
+ splitPoints: [...splitPoints],
+ clipSegments: JSON.parse(JSON.stringify(clipSegments)), // Deep clone to avoid reference issues
+ action: action || 'manual_save', // Track the action that triggered this save
+ };
+
+ // Check if state is significantly different from last saved state
+ const lastState = history[historyPosition];
+
+ // Helper function to compare segments deeply
+ const haveSegmentsChanged = () => {
+ if (!lastState || lastState.clipSegments.length !== newState.clipSegments.length) {
+ return true; // Different length means significant change
+ }
+
+ // Compare each segment's start and end times
+ for (let i = 0; i < newState.clipSegments.length; i++) {
+ const oldSeg = lastState.clipSegments[i];
+ const newSeg = newState.clipSegments[i];
+
+ if (!oldSeg || !newSeg) return true;
+
+ // Check if any time values changed by more than 0.001 seconds (1ms)
+ if (
+ Math.abs(oldSeg.startTime - newSeg.startTime) > 0.001 ||
+ Math.abs(oldSeg.endTime - newSeg.endTime) > 0.001
+ ) {
+ return true;
+ }
+ }
+
+ return false; // No significant changes found
+ };
+
+ const isSignificantChange =
+ !lastState ||
+ lastState.trimStart !== newState.trimStart ||
+ lastState.trimEnd !== newState.trimEnd ||
+ lastState.splitPoints.length !== newState.splitPoints.length ||
+ haveSegmentsChanged();
+
+ // Additionally, check if there's an explicit action from a UI event
+ const hasExplicitActionFlag = newState.action !== undefined;
+
+ // Only proceed if this is a significant change or if explicitly requested
+ if (isSignificantChange || hasExplicitActionFlag) {
+ // Get the current position to avoid closure issues
+ const currentPosition = historyPosition;
+
+ // Use functional updates to ensure we're working with the latest state
+ setHistory((prevHistory) => {
+ // If we're not at the end of history, truncate
+ if (currentPosition < prevHistory.length - 1) {
+ const newHistory = prevHistory.slice(0, currentPosition + 1);
+ return [...newHistory, newState];
+ } else {
+ // Just append to current history
+ return [...prevHistory, newState];
+ }
+ });
+
+ // Update position using functional update
+ setHistoryPosition((prev) => {
+ const newPosition = prev + 1;
+ // "Saved state to history position", newPosition)
+ return newPosition;
+ });
+ } else {
+ // logger.debug("Skipped non-significant state save");
+ }
+ };
+
+ // Listen for trim handle update events
+ useEffect(() => {
+ const handleTrimUpdate = (e: CustomEvent) => {
+ if (e.detail) {
+ const { time, isStart, recordHistory, action } = e.detail;
+
+ if (isStart) {
+ setTrimStart(time);
+ } else {
+ setTrimEnd(time);
+ }
+
+ // Only record in history if explicitly requested
+ if (recordHistory) {
+ // Use a small timeout to ensure the state is updated
+ setTimeout(() => {
+ saveState(action || (isStart ? 'adjust_trim_start' : 'adjust_trim_end'));
+ }, 10);
+ }
+ }
+ };
+
+ document.addEventListener('update-trim', handleTrimUpdate as EventListener);
+
+ return () => {
+ document.removeEventListener('update-trim', handleTrimUpdate as EventListener);
+ };
+ }, []);
+
+ // Listen for segment update events and split-at-time events
+ useEffect(() => {
+ const handleUpdateSegments = (e: CustomEvent) => {
+ if (e.detail && e.detail.segments) {
+ // Check if this is a significant change that should be recorded in history
+ // Default to true to ensure all segment changes are recorded
+ const isSignificantChange = e.detail.recordHistory !== false;
+ // Get the action type if provided
+ const actionType = e.detail.action || 'update_segments';
+
+ // Log the update details
+ logger.debug(
+ `Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}`
+ );
+
+ // Update segment state immediately for UI feedback
+ setClipSegments(e.detail.segments);
+
+ // Always save state to history for non-intermediate actions
+ if (isSignificantChange) {
+ // A slight delay helps avoid race conditions but we need to
+ // ensure we capture the state properly
+ setTimeout(() => {
+ // Deep clone to ensure state is captured correctly
+ const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments));
+
+ // Create a complete state snapshot
+ const stateWithAction: EditorState = {
+ trimStart,
+ trimEnd,
+ splitPoints: [...splitPoints],
+ clipSegments: segmentsClone,
+ action: actionType, // Store the action type in the state
+ };
+
+ // Get the current history position to ensure we're using the latest value
+ const currentHistoryPosition = historyPosition;
+
+ // Update history with the functional pattern to avoid stale closure issues
+ setHistory((prevHistory) => {
+ // If we're not at the end of the history, truncate
+ if (currentHistoryPosition < prevHistory.length - 1) {
+ const newHistory = prevHistory.slice(0, currentHistoryPosition + 1);
+ return [...newHistory, stateWithAction];
+ } else {
+ // Just append to current history
+ return [...prevHistory, stateWithAction];
+ }
+ });
+
+ // Ensure the historyPosition is updated to the correct position
+ setHistoryPosition((prev) => {
+ const newPosition = prev + 1;
+ logger.debug(`Saved state with action: ${actionType} to history position ${newPosition}`);
+ return newPosition;
+ });
+ }, 20); // Slightly increased delay to ensure state updates are complete
+ } else {
+ logger.debug(`Skipped saving state to history for action: ${actionType} (recordHistory=false)`);
+ }
+ }
+ };
+
+ const handleSplitSegment = async (e: Event) => {
+ const customEvent = e as CustomEvent;
+ if (
+ customEvent.detail &&
+ typeof customEvent.detail.time === 'number' &&
+ typeof customEvent.detail.segmentId === 'number'
+ ) {
+ // Get the time and segment ID from the event
+ const timeToSplit = customEvent.detail.time;
+ const segmentId = customEvent.detail.segmentId;
+
+ // Move the current time to the split position
+ seekVideo(timeToSplit);
+
+ // Find the segment to split
+ const segmentToSplit = clipSegments.find((seg) => seg.id === segmentId);
+ if (!segmentToSplit) return;
+
+ // Make sure the split point is within the segment
+ if (timeToSplit <= segmentToSplit.startTime || timeToSplit >= segmentToSplit.endTime) {
+ return; // Can't split outside segment boundaries
+ }
+
+ // Create two new segments from the split
+ const newSegments = [...clipSegments];
+
+ // Remove the original segment
+ const segmentIndex = newSegments.findIndex((seg) => seg.id === segmentId);
+ if (segmentIndex === -1) return;
+
+ newSegments.splice(segmentIndex, 1);
+
+ const firstHalf: Segment = {
+ id: Date.now(),
+ chapterTitle: '', // Temporary title, will be set by renumberAllSegments
+ startTime: segmentToSplit.startTime,
+ endTime: timeToSplit,
+ };
+
+ const secondHalf: Segment = {
+ id: Date.now() + 1,
+ chapterTitle: '', // Temporary title, will be set by renumberAllSegments
+ startTime: timeToSplit,
+ endTime: segmentToSplit.endTime,
+ };
+
+ // Add the new segments
+ newSegments.push(firstHalf, secondHalf);
+
+ // Renumber all segments to ensure proper chronological naming
+ const renumberedSegments = renumberAllSegments(newSegments);
+
+ // Update state
+ setClipSegments(renumberedSegments);
+ saveState('split_segment');
+ }
+ };
+
+ // Handle delete segment event
+ const handleDeleteSegment = async (e: Event) => {
+ const customEvent = e as CustomEvent;
+ if (customEvent.detail && typeof customEvent.detail.segmentId === 'number') {
+ const segmentId = customEvent.detail.segmentId;
+
+ // Find and remove the segment
+ const newSegments = clipSegments.filter((segment) => segment.id !== segmentId);
+
+ if (newSegments.length !== clipSegments.length) {
+ if (newSegments.length === 0) {
+ // Allow empty state - no segments
+ setClipSegments([]);
+ // Reset the trim points as well
+ setTrimStart(0);
+ setTrimEnd(videoRef.current?.duration || 0);
+ setSplitPoints([]);
+ } else {
+ // Renumber remaining segments to ensure proper chronological naming
+ const renumberedSegments = renumberAllSegments(newSegments);
+ setClipSegments(renumberedSegments);
+ }
+ saveState('delete_segment');
+ }
+ }
+ };
+
+ document.addEventListener('update-segments', handleUpdateSegments as EventListener);
+ document.addEventListener('split-segment', handleSplitSegment as EventListener);
+ document.addEventListener('delete-segment', handleDeleteSegment as EventListener);
+
+ return () => {
+ document.removeEventListener('update-segments', handleUpdateSegments as EventListener);
+ document.removeEventListener('split-segment', handleSplitSegment as EventListener);
+ document.removeEventListener('delete-segment', handleDeleteSegment as EventListener);
+ };
+ }, [clipSegments, duration]);
+
+ // Handle trim start change
+ const handleTrimStartChange = (time: number) => {
+ setTrimStart(time);
+ saveState('adjust_trim_start');
+ };
+
+ // Handle trim end change
+ const handleTrimEndChange = (time: number) => {
+ setTrimEnd(time);
+ saveState('adjust_trim_end');
+ };
+
+ // Handle split at current position
+ const handleSplit = async () => {
+ if (!videoRef.current) return;
+
+ // Add current time to split points if not already present
+ if (!splitPoints.includes(currentTime)) {
+ const newSplitPoints = [...splitPoints, currentTime].sort((a, b) => a - b);
+ setSplitPoints(newSplitPoints);
+
+ // Generate segments based on split points
+ const newSegments: Segment[] = [];
+ let startTime = 0;
+
+ for (let i = 0; i <= newSplitPoints.length; i++) {
+ const endTime = i < newSplitPoints.length ? newSplitPoints[i] : duration;
+
+ if (startTime < endTime) {
+ newSegments.push({
+ id: Date.now() + i,
+ chapterTitle: `Chapter ${i + 1}`,
+ startTime,
+ endTime,
+ });
+
+ startTime = endTime;
+ }
+ }
+
+ setClipSegments(newSegments);
+ saveState('create_split_points');
+ }
+ };
+
+ // Handle reset of all edits
+ const handleReset = async () => {
+ setTrimStart(0);
+ setTrimEnd(duration);
+ setSplitPoints([]);
+
+ // Reset to empty state - no default segment
+ setClipSegments([]);
+ saveState('reset_all');
+ };
+
+ // Handle undo
+ const handleUndo = () => {
+ if (historyPosition > 0) {
+ const previousState = history[historyPosition - 1];
+ logger.debug(
+ `** UNDO ** to position ${historyPosition - 1}, action: ${previousState.action}, segments: ${previousState.clipSegments.length}`
+ );
+
+ // Log segment details to help debug
+ logger.debug(
+ 'Segment details after undo:',
+ previousState.clipSegments.map(
+ (seg) =>
+ `ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
+ )
+ );
+
+ // Apply the previous state with deep cloning to avoid reference issues
+ setTrimStart(previousState.trimStart);
+ setTrimEnd(previousState.trimEnd);
+ setSplitPoints([...previousState.splitPoints]);
+ setClipSegments(JSON.parse(JSON.stringify(previousState.clipSegments)));
+ setHistoryPosition(historyPosition - 1);
+
+ // Trigger auto-save by dispatching a custom event
+ setTimeout(() => {
+ const event = new CustomEvent('undo-redo-autosave');
+ document.dispatchEvent(event);
+ }, 10);
+ } else {
+ logger.debug('Cannot undo: at earliest history position');
+ }
+ };
+
+ // Handle redo
+ const handleRedo = () => {
+ if (historyPosition < history.length - 1) {
+ const nextState = history[historyPosition + 1];
+ logger.debug(
+ `** REDO ** to position ${historyPosition + 1}, action: ${nextState.action}, segments: ${nextState.clipSegments.length}`
+ );
+
+ // Log segment details to help debug
+ logger.debug(
+ 'Segment details after redo:',
+ nextState.clipSegments.map(
+ (seg) =>
+ `ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
+ )
+ );
+
+ // Apply the next state with deep cloning to avoid reference issues
+ setTrimStart(nextState.trimStart);
+ setTrimEnd(nextState.trimEnd);
+ setSplitPoints([...nextState.splitPoints]);
+ setClipSegments(JSON.parse(JSON.stringify(nextState.clipSegments)));
+ setHistoryPosition(historyPosition + 1);
+
+ // Trigger auto-save by dispatching a custom event
+ setTimeout(() => {
+ const event = new CustomEvent('undo-redo-autosave');
+ document.dispatchEvent(event);
+ }, 10);
+ } else {
+ logger.debug('Cannot redo: at latest history position');
+ }
+ };
+
+ // Handle zoom level change
+ const handleZoomChange = (level: number) => {
+ setZoomLevel(level);
+ };
+
+ // Handle play/pause of the full video
+ const handlePlay = () => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ if (isPlaying) {
+ // Pause the video
+ video.pause();
+ setIsPlaying(false);
+ } else {
+ // iOS Safari fix: Check for lastSeekedPosition
+ if (typeof window !== 'undefined' && window.lastSeekedPosition > 0) {
+ // Only seek if the position is significantly different
+ if (Math.abs(video.currentTime - window.lastSeekedPosition) > 0.1) {
+ logger.debug('handlePlay: Using lastSeekedPosition', window.lastSeekedPosition);
+ video.currentTime = window.lastSeekedPosition;
+ }
+ }
+
+ // Play the video from current position with proper promise handling
+ video
+ .play()
+ .then(() => {
+ setIsPlaying(true);
+ // Reset lastSeekedPosition after successful play
+ if (typeof window !== 'undefined') {
+ window.lastSeekedPosition = 0;
+ }
+ })
+ .catch((err) => {
+ console.error('Error playing video:', err);
+ setIsPlaying(false); // Reset state if play failed
+ });
+ }
+ };
+
+ // Toggle mute state
+ const toggleMute = () => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ video.muted = !video.muted;
+ setIsMuted(!isMuted);
+ };
+
+ // Handle updating a specific segment
+ const handleSegmentUpdate = (segmentId: number, updates: Partial) => {
+ setClipSegments((prevSegments) =>
+ prevSegments.map((segment) => (segment.id === segmentId ? { ...segment, ...updates } : segment))
+ );
+ setHasUnsavedChanges(true);
+ };
+
+ // Handle saving chapters to database
+ const handleChapterSave = async (chapters: { chapterTitle: string; from: string; to: string }[]) => {
+ try {
+ // Get media ID from window.MEDIA_DATA
+ const mediaId = (window as any).MEDIA_DATA?.mediaId;
+ if (!mediaId) {
+ console.error('No media ID found');
+ return;
+ }
+
+ // Convert chapters to backend expected format and sort by start time
+ let backendChapters = chapters
+ .map((chapter) => ({
+ startTime: chapter.from,
+ endTime: chapter.to,
+ chapterTitle: chapter.chapterTitle,
+ }))
+ .sort((a, b) => {
+ // Parse time strings to seconds for proper comparison
+ const aStartSeconds = parseTimeToSeconds(a.startTime);
+ const bStartSeconds = parseTimeToSeconds(b.startTime);
+ return aStartSeconds - bStartSeconds;
+ });
+
+ // If there's only one chapter that spans the full video duration, send empty array
+ if (backendChapters.length === 1) {
+ const singleChapter = backendChapters[0];
+ const startSeconds = parseTimeToSeconds(singleChapter.startTime);
+ const endSeconds = parseTimeToSeconds(singleChapter.endTime);
+
+ // Check if this single chapter spans the entire video (within 0.1 second tolerance)
+ const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1;
+
+ if (isFullVideoChapter) {
+ logger.debug('Manual save: Single chapter spans full video - sending empty array');
+ backendChapters = [];
+ }
+ }
+
+ // Create the API request body
+ const requestData = {
+ chapters: backendChapters,
+ };
+
+ // Make API call to save chapters
+ const csrfToken = getCsrfToken();
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ };
+
+ if (csrfToken) {
+ headers['X-CSRFToken'] = csrfToken;
+ }
+
+ const response = await fetch(`/api/v1/media/${mediaId}/chapters`, {
+ // TODO: Backend API is not ready yet
+ method: 'POST',
+ headers,
+ body: JSON.stringify(requestData),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to save chapters: ${response.status}`);
+ }
+
+ const result = await response.json();
+
+ // Mark as saved - no unsaved changes
+ setHasUnsavedChanges(false);
+ } catch (error) {
+ console.error('Error saving chapters:', error);
+ // You might want to show a user-friendly error message here
+ }
+ };
+
+ // Helper function to get CSRF token
+ const getCsrfToken = (): string => {
+ const name = 'csrftoken';
+ const value = `; ${document.cookie}`;
+ const parts = value.split(`; ${name}=`);
+ if (parts.length === 2) return parts.pop()?.split(';').shift() || '';
+ return '';
+ };
+
+ // Handle selected segment change
+ const handleSelectedSegmentChange = (segmentId: number | null) => {
+ setSelectedSegmentId(segmentId);
+ };
+
+ // Handle seeking with mobile check
+ const handleMobileSafeSeek = (time: number) => {
+ // Only allow seeking if not on mobile or if video has been played
+ if (!isMobile || videoInitialized) {
+ seekVideo(time);
+ }
+ };
+
+ // Check if device is mobile
+ const isMobile =
+ typeof window !== 'undefined' &&
+ /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(navigator.userAgent);
+
+ // Add videoInitialized state
+ const [videoInitialized, setVideoInitialized] = useState(false);
+
+ // Effect to handle segments playback
+ useEffect(() => {
+ if (!isPlayingSegments || !videoRef.current) return;
+
+ const video = videoRef.current;
+ const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
+
+ const handleSegmentsPlayback = () => {
+ const currentSegment = orderedSegments[currentSegmentIndex];
+ if (!currentSegment) return;
+
+ const currentTime = video.currentTime;
+
+ // If we're before the current segment's start, jump to it
+ if (currentTime < currentSegment.startTime) {
+ video.currentTime = currentSegment.startTime;
+ return;
+ }
+
+ // If we've reached the end of the current segment
+ if (currentTime >= currentSegment.endTime - 0.01) {
+ if (currentSegmentIndex < orderedSegments.length - 1) {
+ // Move to next segment
+ const nextSegment = orderedSegments[currentSegmentIndex + 1];
+ video.currentTime = nextSegment.startTime;
+ setCurrentSegmentIndex(currentSegmentIndex + 1);
+
+ // If video is somehow paused, ensure it keeps playing
+ if (video.paused) {
+ logger.debug('Ensuring playback continues to next segment');
+ video.play().catch((err) => {
+ console.error('Error continuing segment playback:', err);
+ });
+ }
+ } else {
+ // End of all segments - only pause when we reach the very end
+ video.pause();
+ setIsPlayingSegments(false);
+ setCurrentSegmentIndex(0);
+ video.removeEventListener('timeupdate', handleSegmentsPlayback);
+ }
+ }
+ };
+
+ video.addEventListener('timeupdate', handleSegmentsPlayback);
+
+ // Start playing if not already playing
+ if (video.paused && orderedSegments.length > 0) {
+ video.currentTime = orderedSegments[0].startTime;
+ video.play().catch(console.error);
+ }
+
+ return () => {
+ video.removeEventListener('timeupdate', handleSegmentsPlayback);
+ };
+ }, [isPlayingSegments, currentSegmentIndex, clipSegments]);
+
+ // Effect to handle manual segment index updates during segments playback
+ useEffect(() => {
+ const handleSegmentIndexUpdate = (event: CustomEvent) => {
+ const { segmentIndex } = event.detail;
+ if (isPlayingSegments && segmentIndex !== currentSegmentIndex) {
+ logger.debug(`Updating current segment index from ${currentSegmentIndex} to ${segmentIndex}`);
+ setCurrentSegmentIndex(segmentIndex);
+ }
+ };
+
+ document.addEventListener('update-segment-index', handleSegmentIndexUpdate as EventListener);
+
+ return () => {
+ document.removeEventListener('update-segment-index', handleSegmentIndexUpdate as EventListener);
+ };
+ }, [isPlayingSegments, currentSegmentIndex]);
+
+ // Handle play chapters
+ const handlePlaySegments = () => {
+ const video = videoRef.current;
+ if (!video || clipSegments.length === 0) return;
+
+ if (isPlayingSegments) {
+ // Stop segments playback
+ video.pause();
+ setIsPlayingSegments(false);
+ setCurrentSegmentIndex(0);
+ } else {
+ // Start segments playback
+ setIsPlayingSegments(true);
+ setCurrentSegmentIndex(0);
+
+ // Start segments playback
+
+ // Sort segments by start time
+ const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
+
+ // Start from the first segment
+ video.currentTime = orderedSegments[0].startTime;
+
+ // Start playback with proper error handling
+ video.play().catch((err) => {
+ console.error('Error starting segments playback:', err);
+ setIsPlayingSegments(false);
+ });
+
+ logger.debug('Starting playback of all segments continuously');
+ }
+ };
+
+ return {
+ videoRef,
+ currentTime,
+ duration,
+ isPlaying,
+ setIsPlaying,
+ isMuted,
+ isPlayingSegments,
+ trimStart,
+ trimEnd,
+ splitPoints,
+ zoomLevel,
+ clipSegments,
+ selectedSegmentId,
+ hasUnsavedChanges,
+ historyPosition,
+ history,
+ handleTrimStartChange,
+ handleTrimEndChange,
+ handleZoomChange,
+ handleMobileSafeSeek,
+ handleSplit,
+ handleReset,
+ handleUndo,
+ handleRedo,
+ handlePlaySegments,
+ toggleMute,
+ handleSegmentUpdate,
+ handleChapterSave,
+ handleSelectedSegmentChange,
+ isMobile,
+ videoInitialized,
+ setVideoInitialized,
+ initializeSafariIfNeeded, // Expose Safari initialization helper
+ };
+};
+
+export default useVideoChapters;
diff --git a/frontend-tools/chapters-editor/client/src/index.css b/frontend-tools/chapters-editor/client/src/index.css
new file mode 100644
index 00000000..418a72d5
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/index.css
@@ -0,0 +1,796 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ --foreground: 20 14.3% 4.1%;
+ --muted: 60 4.8% 95.9%;
+ --muted-foreground: 25 5.3% 44.7%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 20 14.3% 4.1%;
+ --card: 0 0% 100%;
+ --card-foreground: 20 14.3% 4.1%;
+ --border: 20 5.9% 90%;
+ --input: 20 5.9% 90%;
+ --primary: 207 90% 54%;
+ --primary-foreground: 211 100% 99%;
+ --secondary: 30 84% 54%; /* Changed from red (0) to orange (30) */
+ --secondary-foreground: 60 9.1% 97.8%;
+ --accent: 60 4.8% 95.9%;
+ --accent-foreground: 24 9.8% 10%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 60 9.1% 97.8%;
+ --ring: 20 14.3% 4.1%;
+ --radius: 0.5rem;
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+}
+
+/* Video Player Styles */
+.video-player {
+ position: relative;
+ width: 100%;
+ background-color: #000;
+ overflow: hidden;
+ border-radius: 0.5rem;
+}
+
+.video-controls {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+}
+
+.video-current-time {
+ color: #fff;
+ font-weight: 500;
+}
+
+.video-progress {
+ position: relative;
+ height: 4px;
+ background-color: rgba(255, 255, 255, 0.3);
+ border-radius: 2px;
+ margin-bottom: 1rem;
+}
+
+.video-progress-fill {
+ position: absolute;
+ left: 0;
+ top: 0;
+ height: 100%;
+ background-color: hsl(var(--primary));
+ border-radius: 2px;
+}
+
+.video-scrubber {
+ position: absolute;
+ width: 12px;
+ height: 12px;
+ margin-left: -6px;
+ background-color: white;
+ border-radius: 50%;
+ top: -4px;
+}
+
+/* Play/Pause indicator for video player */
+.video-player-container {
+ position: relative;
+ overflow: hidden;
+}
+
+.play-pause-indicator {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 70px;
+ height: 70px;
+ border-radius: 50%;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 20;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ pointer-events: none;
+ background-position: center;
+ background-repeat: no-repeat;
+}
+
+.play-icon {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E");
+}
+
+.pause-icon {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M6 19h4V5H6v14zm8-14v14h4V5h-4z'/%3E%3C/svg%3E");
+}
+
+/* Only show play/pause indicator on hover */
+.video-player-container:hover .play-pause-indicator {
+ opacity: 1;
+}
+
+/* Timeline Styles */
+.timeline-scroll-container {
+ height: 6rem;
+ border-radius: 0.375rem;
+ overflow-x: auto;
+ overflow-y: hidden;
+ margin-bottom: 0.75rem;
+ background-color: #eee; /* Very light gray background */
+ position: relative;
+}
+
+.timeline-container {
+ position: relative;
+ background-color: #eee; /* Very light gray background */
+ height: 6rem;
+ width: 100%;
+ cursor: pointer;
+ transition: width 0.3s ease;
+}
+
+.timeline-marker {
+ position: absolute;
+ top: -10px;
+ height: calc(100% + 10px);
+ width: 2px;
+ background-color: red;
+ z-index: 100; /* Highest z-index to stay on top of everything */
+ pointer-events: none; /* Allow clicks to pass through to segments underneath */
+ box-shadow: 0 0 4px rgba(255, 0, 0, 0.5); /* Add subtle glow effect */
+}
+
+.trim-line-marker {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
+ z-index: 10;
+}
+
+.trim-handle {
+ width: 8px;
+ background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ cursor: ew-resize;
+ z-index: 15;
+}
+
+.trim-handle.left {
+ left: -4px;
+}
+
+.trim-handle.right {
+ right: -4px;
+}
+
+.split-point {
+ position: absolute;
+ width: 2px;
+ background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
+ top: 0;
+ bottom: 0;
+ z-index: 5;
+}
+
+/* Clip Segment styles */
+.clip-segment {
+ position: absolute;
+ height: 95%;
+ top: 0;
+ border-radius: 4px;
+ background-size: cover;
+ background-position: center;
+ background-blend-mode: soft-light;
+ /* Border is now set in the color-specific rules */
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+ overflow: hidden;
+ cursor: grab;
+ user-select: none;
+ transition:
+ box-shadow 0.2s,
+ transform 0.1s;
+ /* Original z-index for stacking order based on segment ID */
+ z-index: 15;
+}
+
+/* No background colors for segments, just borders with 2-color scheme */
+.clip-segment:nth-child(odd),
+.segment-color-1,
+.segment-color-3,
+.segment-color-5,
+.segment-color-7 {
+ background-color: transparent;
+ border: 2px solid rgba(0, 123, 255, 0.9); /* Blue border */
+}
+.clip-segment:nth-child(even),
+.segment-color-2,
+.segment-color-4,
+.segment-color-6,
+.segment-color-8 {
+ background-color: transparent;
+ border: 2px solid rgba(108, 117, 125, 0.9); /* Gray border */
+}
+
+.clip-segment:hover {
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ transform: translateY(-1px);
+ filter: brightness(1.1);
+}
+
+.clip-segment:active {
+ cursor: grabbing;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
+ transform: translateY(0);
+}
+
+.clip-segment.selected {
+ border-width: 3px; /* Make border thicker instead of changing color */
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+ z-index: 25;
+ filter: brightness(1.2);
+}
+
+.clip-segment-info {
+ background-color: rgba(226, 230, 234, 0.9); /* Light gray background */
+ color: #000000; /* Pure black text */
+ padding: 6px 8px;
+ font-size: 0.7rem;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ border-radius: 4px 4px 0 0;
+ z-index: 2;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.clip-segment-name {
+ font-weight: bold;
+ color: #000000; /* Pure black text */
+}
+
+.clip-segment-time {
+ font-size: 0.65rem;
+ color: #000000; /* Pure black text */
+}
+
+.clip-segment-duration {
+ font-size: 0.65rem;
+ color: #000000; /* Pure black text */
+ background: rgba(179, 217, 255, 0.4); /* Light blue background */
+ padding: 1px 4px;
+ border-radius: 2px;
+ display: inline-block;
+ margin-top: 2px;
+}
+
+.clip-segment-handle {
+ position: absolute;
+ width: 8px;
+ top: 0;
+ bottom: 0;
+ background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
+ cursor: ew-resize;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.clip-segment-handle::after {
+ content: "↔";
+ color: white;
+ font-size: 12px;
+ text-shadow: 0 0 2px rgba(0, 0, 0, 0.8);
+}
+
+.clip-segment-handle.left {
+ left: 0;
+}
+
+.clip-segment-handle.right {
+ right: 0;
+}
+
+.clip-segment-handle:hover {
+ background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
+ width: 10px;
+}
+
+/* Zoom Slider */
+input[type="range"] {
+ -webkit-appearance: none;
+ height: 6px;
+ background: #e0e0e0;
+ border-radius: 3px;
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ height: 16px;
+ width: 16px;
+ border-radius: 50%;
+ background: rgba(0, 123, 255, 0.9); /* Primary blue color */
+ cursor: pointer;
+}
+
+/* Tooltip styles */
+[data-tooltip] {
+ position: relative;
+ cursor: pointer;
+}
+
+[data-tooltip]::before {
+ content: attr(data-tooltip);
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ margin-bottom: 8px;
+ background-color: rgba(0, 0, 0, 0.8);
+ color: white;
+ padding: 5px 10px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ white-space: nowrap;
+ z-index: 1000;
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.2s,
+ visibility 0.2s;
+ pointer-events: none;
+}
+
+[data-tooltip]::after {
+ content: "";
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border-width: 5px;
+ border-style: solid;
+ border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
+ margin-bottom: 0px;
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.2s,
+ visibility 0.2s;
+ pointer-events: none;
+}
+
+/* Only show tooltips on devices with mouse hover capability */
+@media (hover: hover) and (pointer: fine) {
+ [data-tooltip]:hover::before,
+ [data-tooltip]:hover::after {
+ opacity: 1;
+ visibility: visible;
+ }
+}
+
+/* Hide button tooltips (simple hover labels) on touch devices */
+@media (pointer: coarse) {
+ [data-tooltip]::before,
+ [data-tooltip]::after {
+ display: none !important;
+ content: none !important;
+ opacity: 0 !important;
+ visibility: hidden !important;
+ pointer-events: none !important;
+ }
+}
+
+/* Fix for buttons with disabled state */
+button[disabled][data-tooltip]::before,
+button[disabled][data-tooltip]::after {
+ opacity: 0.5;
+}
+
+/* Custom tooltip for action buttons - completely different approach */
+.tooltip-action-btn {
+ position: relative;
+}
+
+.tooltip-action-btn[data-tooltip]::before,
+.tooltip-action-btn[data-tooltip]::after {
+ /* Reset standard tooltip styles first */
+ opacity: 0;
+ visibility: hidden;
+ position: absolute;
+ pointer-events: none;
+ transition: all 0.3s ease;
+}
+
+.tooltip-action-btn[data-tooltip]::before {
+ content: attr(data-tooltip);
+ background-color: rgba(0, 0, 0, 0.8);
+ color: white;
+ font-size: 12px;
+ padding: 4px 8px;
+ border-radius: 3px;
+ white-space: nowrap;
+
+ /* Position below the button */
+ bottom: -35px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 9999;
+}
+
+.tooltip-action-btn[data-tooltip]::after {
+ content: "";
+ border-width: 5px;
+ border-style: solid;
+ border-color: transparent transparent rgba(0, 0, 0, 0.8) transparent;
+
+ /* Position the arrow */
+ bottom: -15px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 9999;
+}
+
+/* Only show tooltips on devices with mouse hover capability */
+@media (hover: hover) and (pointer: fine) {
+ .tooltip-action-btn:hover[data-tooltip]::before,
+ .tooltip-action-btn:hover[data-tooltip]::after {
+ opacity: 1;
+ visibility: visible;
+ }
+}
+
+/* Ensure tooltip container has proper space */
+
+/* Segment tooltip styles */
+.segment-tooltip {
+ background-color: rgba(179, 217, 255, 0.95); /* Light blue color */
+ color: #000000; /* Pure black text */
+ border-radius: 4px;
+ padding: 6px; /* Regular padding now that we have custom tooltips */
+ min-width: 140px; /* Increased width to accommodate the new delete button */
+ z-index: 1000; /* Increased z-index */
+ box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
+}
+
+.segment-tooltip::after {
+ content: "";
+ position: absolute;
+ bottom: -6px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0;
+ height: 0;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-top: 6px solid rgba(179, 217, 255, 0.95); /* Light blue color */
+}
+
+.tooltip-time {
+ font-size: 0.85rem;
+ font-weight: bold;
+ text-align: center;
+ margin-bottom: 6px;
+ color: #000000; /* Pure black text */
+}
+
+.tooltip-actions {
+ display: flex;
+ justify-content: space-between;
+ gap: 5px;
+ position: relative;
+}
+
+.tooltip-action-btn {
+ background-color: rgba(0, 123, 255, 0.2); /* Light blue background */
+ border: none;
+ border-radius: 3px;
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ padding: 6px;
+ transition: background-color 0.2s;
+ min-width: 20px !important;
+}
+
+.tooltip-action-btn:hover {
+ background-color: rgba(0, 123, 255, 0.4); /* Slightly darker on hover */
+}
+
+.tooltip-action-btn svg {
+ width: 100%;
+ height: 100%;
+ stroke: currentColor;
+}
+
+/* Adjust for the hand icons specifically */
+.tooltip-action-btn.set-in svg,
+.tooltip-action-btn.set-out svg {
+ width: 100%;
+ height: 100%;
+ margin: 0 auto;
+ fill: currentColor;
+ stroke: none;
+}
+
+/* Empty space tooltip styling */
+.empty-space-tooltip {
+ background-color: white;
+ border-radius: 6px;
+ box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.15);
+ padding: 8px;
+ z-index: 50;
+ min-width: 120px;
+ text-align: center;
+ position: relative;
+}
+
+.empty-space-tooltip::after {
+ content: "";
+ position: absolute;
+ bottom: -8px;
+ left: 50%;
+ transform: translateX(-50%);
+ border-width: 8px 8px 0;
+ border-style: solid;
+ border-color: white transparent transparent;
+}
+
+.tooltip-action-btn.new-segment {
+ width: auto;
+ padding: 6px 10px;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.tooltip-btn-text {
+ font-size: 0.8rem;
+ white-space: nowrap;
+ color: #000000; /* Pure black text */
+}
+
+.icon-new-segment {
+ width: 20px;
+ height: 20px;
+}
+
+/* Zoom dropdown styling */
+.zoom-dropdown-container {
+ position: relative;
+}
+
+.zoom-button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background-color: rgba(108, 117, 125, 0.8);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 8px 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.zoom-button:hover {
+ background-color: rgba(108, 117, 125, 1);
+}
+
+.zoom-dropdown {
+ background-color: white;
+ border-radius: 4px;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.zoom-option {
+ padding: 8px 12px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.zoom-option:hover {
+ background-color: rgba(0, 123, 255, 0.1);
+}
+
+.zoom-option.selected {
+ background-color: rgba(0, 123, 255, 0.2);
+ font-weight: 500;
+}
+
+/* Save buttons styling */
+.save-button,
+.save-copy-button,
+.save-segments-button {
+ background-color: rgba(0, 123, 255, 0.8);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 8px 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.save-button:hover,
+.save-copy-button:hover {
+ background-color: rgba(0, 123, 255, 1);
+}
+
+.save-copy-button {
+ background-color: rgba(108, 117, 125, 0.8);
+}
+
+.save-copy-button:hover {
+ background-color: rgba(108, 117, 125, 1);
+}
+
+/* Time navigation input styling */
+.time-nav-label {
+ font-weight: 500;
+ font-size: 0.9rem;
+}
+
+.time-input {
+ padding: 6px 10px;
+ border-radius: 4px;
+ border: 1px solid #ccc;
+ width: 150px;
+ font-family: monospace;
+}
+
+.time-button-group {
+ display: flex;
+ gap: 5px;
+}
+
+.time-button {
+ background-color: rgba(108, 117, 125, 0.8);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 6px 8px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.time-button:hover {
+ background-color: rgba(108, 117, 125, 1);
+}
+
+/* Timeline navigation and zoom controls responsiveness */
+.timeline-controls {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap; /* Allow wrapping on smaller screens */
+ padding: 12px;
+ background-color: #f5f5f5;
+ border-radius: 6px;
+ margin-top: 15px;
+}
+
+.time-navigation {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.controls-right {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+/* Media queries for responsive design */
+@media (max-width: 768px) {
+ .timeline-controls {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 15px;
+ }
+
+ .controls-right {
+ margin-top: 10px;
+ width: 100%;
+ justify-content: flex-start;
+ text-align: center;
+ align-items: center;
+ justify-content: center;
+ }
+}
+
+/* Timeline header styling */
+.timeline-header {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ margin-bottom: 10px;
+ flex-wrap: wrap;
+}
+
+.timeline-title {
+ font-weight: bold;
+ margin-right: 20px;
+}
+
+.timeline-title-text {
+ font-size: 1.1rem;
+}
+
+.current-time,
+.duration-time {
+ white-space: nowrap;
+}
+
+.time-code {
+ font-family: monospace;
+ font-weight: 500;
+}
+
+@media (max-width: 480px) {
+ .timeline-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ }
+
+ .time-navigation {
+ width: 100%;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ }
+
+ .time-button-group {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ margin-top: 10px;
+ }
+
+ .controls-right {
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ .save-button,
+ .save-copy-button {
+ margin-top: 8px;
+ width: 100%;
+ }
+
+ .zoom-dropdown-container {
+ width: 100%;
+ }
+
+ .zoom-button {
+ width: 100%;
+ justify-content: center;
+ }
+}
diff --git a/frontend-tools/chapters-editor/client/src/lib/logger.ts b/frontend-tools/chapters-editor/client/src/lib/logger.ts
new file mode 100644
index 00000000..e68e60b9
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/lib/logger.ts
@@ -0,0 +1,31 @@
+/**
+ * A consistent logger utility that only outputs debug messages in development
+ * but always shows errors, warnings, and info messages.
+ */
+const logger = {
+ /**
+ * Logs debug messages only in development environment
+ */
+ debug: (...args: any[]) => {
+ if (process.env.NODE_ENV === 'development') {
+ console.debug(...args);
+ }
+ },
+
+ /**
+ * Always logs error messages
+ */
+ error: (...args: any[]) => console.error(...args),
+
+ /**
+ * Always logs warning messages
+ */
+ warn: (...args: any[]) => console.warn(...args),
+
+ /**
+ * Always logs info messages
+ */
+ info: (...args: any[]) => console.info(...args),
+};
+
+export default logger;
diff --git a/frontend-tools/chapters-editor/client/src/lib/queryClient.ts b/frontend-tools/chapters-editor/client/src/lib/queryClient.ts
new file mode 100644
index 00000000..a96e8122
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/lib/queryClient.ts
@@ -0,0 +1,51 @@
+import { QueryClient, QueryFunction } from '@tanstack/react-query';
+
+async function throwIfResNotOk(res: Response) {
+ if (!res.ok) {
+ const text = (await res.text()) || res.statusText;
+ throw new Error(`${res.status}: ${text}`);
+ }
+}
+
+export async function apiRequest(method: string, url: string, data?: unknown | undefined): Promise {
+ const res = await fetch(url, {
+ method,
+ headers: data ? { 'Content-Type': 'application/json' } : {},
+ body: data ? JSON.stringify(data) : undefined,
+ credentials: 'include',
+ });
+
+ await throwIfResNotOk(res);
+ return res;
+}
+
+type UnauthorizedBehavior = 'returnNull' | 'throw';
+export const getQueryFn: (options: { on401: UnauthorizedBehavior }) => QueryFunction =
+ ({ on401: unauthorizedBehavior }) =>
+ async ({ queryKey }) => {
+ const res = await fetch(queryKey[0] as string, {
+ credentials: 'include',
+ });
+
+ if (unauthorizedBehavior === 'returnNull' && res.status === 401) {
+ return null;
+ }
+
+ await throwIfResNotOk(res);
+ return await res.json();
+ };
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ queryFn: getQueryFn({ on401: 'throw' }),
+ refetchInterval: false,
+ refetchOnWindowFocus: false,
+ staleTime: Infinity,
+ retry: false,
+ },
+ mutations: {
+ retry: false,
+ },
+ },
+});
diff --git a/frontend-tools/chapters-editor/client/src/lib/timeUtils.ts b/frontend-tools/chapters-editor/client/src/lib/timeUtils.ts
new file mode 100644
index 00000000..78396acd
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/lib/timeUtils.ts
@@ -0,0 +1,34 @@
+/**
+ * Format seconds to HH:MM:SS.mmm format with millisecond precision
+ */
+export const formatDetailedTime = (seconds: number): string => {
+ if (isNaN(seconds)) return '00:00:00.000';
+
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const remainingSeconds = Math.floor(seconds % 60);
+ const milliseconds = Math.round((seconds % 1) * 1000);
+
+ const formattedHours = String(hours).padStart(2, '0');
+ const formattedMinutes = String(minutes).padStart(2, '0');
+ const formattedSeconds = String(remainingSeconds).padStart(2, '0');
+ const formattedMilliseconds = String(milliseconds).padStart(3, '0');
+
+ return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
+};
+
+/**
+ * Format seconds to MM:SS format - now uses the detailed format with hours and milliseconds
+ */
+export const formatTime = (seconds: number): string => {
+ // Use the detailed format instead of the old MM:SS format
+ return formatDetailedTime(seconds);
+};
+
+/**
+ * Format seconds to HH:MM:SS format - now uses the detailed format with milliseconds
+ */
+export const formatLongTime = (seconds: number): string => {
+ // Use the detailed format
+ return formatDetailedTime(seconds);
+};
diff --git a/frontend-tools/chapters-editor/client/src/lib/utils.ts b/frontend-tools/chapters-editor/client/src/lib/utils.ts
new file mode 100644
index 00000000..3877c89f
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/frontend-tools/chapters-editor/client/src/lib/videoUtils.ts b/frontend-tools/chapters-editor/client/src/lib/videoUtils.ts
new file mode 100644
index 00000000..8939d66a
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/lib/videoUtils.ts
@@ -0,0 +1,17 @@
+/**
+ * Generate a solid color background for a segment
+ * Returns a CSS color based on the segment position
+ */
+export const generateSolidColor = (time: number, duration: number): string => {
+ // Use the time position to create different colors
+ // This gives each segment a different color without needing an image
+ const position = Math.min(Math.max(time / (duration || 1), 0), 1);
+
+ // Calculate color based on position
+ // Use an extremely light blue-based color palette
+ const hue = 210; // Blue base
+ const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated)
+ const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light)
+
+ return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
+};
\ No newline at end of file
diff --git a/frontend-tools/chapters-editor/client/src/main.tsx b/frontend-tools/chapters-editor/client/src/main.tsx
new file mode 100644
index 00000000..6d48884c
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/main.tsx
@@ -0,0 +1,39 @@
+import { createRoot } from 'react-dom/client';
+import App from './App';
+import './index.css';
+
+if (typeof window !== 'undefined') {
+ window.MEDIA_DATA = {
+ videoUrl: '',
+ mediaId: '',
+ posterUrl: ''
+ };
+ window.lastSeekedPosition = 0;
+}
+
+declare global {
+ interface Window {
+ MEDIA_DATA: {
+ videoUrl: string;
+ mediaId: string;
+ posterUrl?: string;
+ };
+ seekToFunction?: (time: number) => void;
+ lastSeekedPosition: number;
+ }
+}
+
+// Mount the components when the DOM is ready
+const mountComponents = () => {
+ const chaptersEditorContainer = document.getElementById('chapters-editor-root');
+ if (chaptersEditorContainer) {
+ const chaptersEditorRoot = createRoot(chaptersEditorContainer);
+ chaptersEditorRoot.render( );
+ }
+};
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', mountComponents);
+} else {
+ mountComponents();
+}
diff --git a/frontend-tools/chapters-editor/client/src/services/videoApi.ts b/frontend-tools/chapters-editor/client/src/services/videoApi.ts
new file mode 100644
index 00000000..189a652b
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/services/videoApi.ts
@@ -0,0 +1,86 @@
+// API service for video trimming operations
+import logger from '../lib/logger';
+
+// Helper function to simulate delay
+const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+// Auto-save interface
+interface AutoSaveRequest {
+ chapters: {
+ startTime: string;
+ endTime: string;
+ chapterTitle?: string;
+ }[];
+}
+
+interface AutoSaveResponse {
+ success: boolean;
+ status?: string;
+ timestamp: string;
+ chapters?: {
+ startTime: string;
+ endTime: string;
+ chapterTitle: string;
+ }[];
+ updated_at?: string;
+ error?: string;
+}
+
+// Auto-save API function
+export const autoSaveVideo = async (mediaId: string, data: AutoSaveRequest): Promise => {
+ try {
+ const response = await fetch(`/api/v1/media/${mediaId}/chapters`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ });
+
+ logger.debug('response', response);
+
+ if (!response.ok) {
+ // For error responses, return with error status
+ if (response.status === 404) {
+ // If endpoint not ready (404), return mock success response
+ const timestamp = new Date().toISOString();
+ return {
+ success: true,
+ timestamp: timestamp,
+ };
+ } else {
+ // Handle other error responses
+ try {
+ const errorData = await response.json();
+ return {
+ success: false,
+ timestamp: new Date().toISOString(),
+ error: errorData.error || 'Auto-save failed (videoApi.ts)',
+ };
+ } catch (parseError) {
+ return {
+ success: false,
+ timestamp: new Date().toISOString(),
+ error: 'Auto-save failed (videoApi.ts)',
+ };
+ }
+ }
+ }
+
+ // Successful response
+ const jsonResponse = await response.json();
+
+ // Check if the response has the expected format
+ return {
+ success: true,
+ timestamp: jsonResponse.updated_at || new Date().toISOString(),
+ ...jsonResponse,
+ };
+
+ } catch (error) {
+ // For any fetch errors, return mock success response
+ const timestamp = new Date().toISOString();
+ return {
+ success: true,
+ timestamp: timestamp,
+ };
+ }
+};
diff --git a/frontend-tools/chapters-editor/client/src/styles/ClipSegments.css b/frontend-tools/chapters-editor/client/src/styles/ClipSegments.css
new file mode 100644
index 00000000..b9356045
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/styles/ClipSegments.css
@@ -0,0 +1,338 @@
+#chapters-editor-root {
+ /* Tooltip styles - only on desktop where hover is available */
+ @media (hover: hover) and (pointer: fine) {
+ [data-tooltip] {
+ position: relative;
+ }
+
+ [data-tooltip]:before {
+ content: attr(data-tooltip);
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ margin-bottom: 5px;
+ background-color: rgba(0, 0, 0, 0.8);
+ color: white;
+ text-align: center;
+ padding: 5px 10px;
+ border-radius: 3px;
+ font-size: 12px;
+ white-space: nowrap;
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.2s,
+ visibility 0.2s;
+ z-index: 1000;
+ pointer-events: none;
+ }
+
+ [data-tooltip]:after {
+ content: "";
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border-width: 5px;
+ border-style: solid;
+ border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.2s,
+ visibility 0.2s;
+ pointer-events: none;
+ }
+
+ [data-tooltip]:hover:before,
+ [data-tooltip]:hover:after {
+ opacity: 1;
+ visibility: visible;
+ }
+ }
+
+ /* Hide button tooltips on touch devices */
+ @media (pointer: coarse) {
+ [data-tooltip]:before,
+ [data-tooltip]:after {
+ display: none !important;
+ content: none !important;
+ opacity: 0 !important;
+ visibility: hidden !important;
+ pointer-events: none !important;
+ }
+ }
+ .clip-segments-container {
+ margin-top: 1rem;
+ background-color: white;
+ border-radius: 0.5rem;
+ padding: 1rem;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+ }
+
+ .clip-segments-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.75rem;
+ }
+
+ .clip-segments-title {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--foreground, #333);
+ margin: 0;
+ }
+
+ .save-chapters-button {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background-color: #3b82f6;
+ color: white;
+ border: none;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background-color: #2563eb;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
+ }
+
+ &.has-changes {
+ background-color: #10b981;
+ animation: pulse-green 2s infinite;
+ }
+
+ &.has-changes:hover {
+ background-color: #059669;
+ }
+
+ svg {
+ width: 1rem;
+ height: 1rem;
+ }
+ }
+
+ @keyframes pulse-green {
+ 0%,
+ 100% {
+ background-color: #10b981;
+ }
+ 50% {
+ background-color: #34d399;
+ }
+ }
+
+ .chapter-editor {
+ background-color: #f8fafc;
+ border: 2px solid #3b82f6;
+ border-radius: 0.5rem;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ transition: all 0.2s ease;
+ }
+
+ .chapter-editor-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.75rem;
+ }
+
+ .chapter-editor-header h4 {
+ margin: 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: #1f2937;
+ }
+
+ .chapter-editor-segment {
+ font-size: 0.75rem;
+ color: #6b7280;
+ background-color: #e5e7eb;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ }
+
+ .chapter-title-input {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ resize: vertical;
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+
+ &:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ }
+
+ &::placeholder {
+ color: #9ca3af;
+ }
+ }
+
+ .chapter-editor-info {
+ margin-top: 0.5rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+ font-style: italic;
+ }
+
+ .segment-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.25rem;
+ margin-bottom: 0.5rem;
+ transition: all 0.2s ease;
+
+ &:hover {
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+ }
+
+ &.selected {
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ background-color: rgba(59, 130, 246, 0.05);
+ }
+ }
+
+ .segment-content {
+ display: flex;
+ align-items: center;
+ }
+
+ .segment-info {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .segment-title {
+ font-weight: 500;
+ font-size: 0.875rem;
+ color: black;
+ }
+
+ .chapter-title {
+ color: #1f2937;
+ font-weight: 600;
+ }
+
+ .default-title {
+ color: #6b7280;
+ font-style: italic;
+ }
+
+ .segment-time {
+ font-size: 0.75rem;
+ color: black;
+ }
+
+ .segment-duration {
+ font-size: 0.75rem;
+ margin-top: 0.25rem;
+ display: inline-block;
+ background-color: #f3f4f6;
+ padding: 0 0.5rem;
+ border-radius: 0.25rem;
+ color: black;
+ }
+
+ .segment-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ .delete-button {
+ padding: 0.375rem;
+ color: #4b5563;
+ background-color: #e5e7eb;
+ border-radius: 9999px;
+ border: none;
+ cursor: pointer;
+ transition:
+ background-color 0.2s,
+ color 0.2s;
+ min-width: auto;
+
+ &:hover {
+ color: black;
+ background-color: #d1d5db;
+ }
+
+ svg {
+ height: 1rem;
+ width: 1rem;
+ }
+ }
+
+ .empty-message {
+ padding: 1rem;
+ text-align: center;
+ color: rgba(51, 51, 51, 0.7);
+ }
+
+ .segment-color-1 {
+ background-color: rgba(59, 130, 246, 0.15);
+ }
+ .segment-color-2 {
+ background-color: rgba(16, 185, 129, 0.15);
+ }
+ .segment-color-3 {
+ background-color: rgba(245, 158, 11, 0.15);
+ }
+ .segment-color-4 {
+ background-color: rgba(239, 68, 68, 0.15);
+ }
+ .segment-color-5 {
+ background-color: rgba(139, 92, 246, 0.15);
+ }
+ .segment-color-6 {
+ background-color: rgba(236, 72, 153, 0.15);
+ }
+ .segment-color-7 {
+ background-color: rgba(6, 182, 212, 0.15);
+ }
+ .segment-color-8 {
+ background-color: rgba(250, 204, 21, 0.15);
+ }
+
+ /* Responsive styles */
+ @media (max-width: 768px) {
+ .clip-segments-header {
+ flex-direction: column;
+ gap: 0.75rem;
+ align-items: stretch;
+ }
+
+ .save-chapters-button {
+ justify-content: center;
+ }
+
+ .chapter-editor-header {
+ flex-direction: column;
+ gap: 0.5rem;
+ align-items: flex-start;
+ }
+
+ .chapter-editor-segment {
+ align-self: stretch;
+ text-align: center;
+ }
+ }
+}
diff --git a/frontend-tools/chapters-editor/client/src/styles/EditingTools.css b/frontend-tools/chapters-editor/client/src/styles/EditingTools.css
new file mode 100644
index 00000000..dcc4c4cd
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/styles/EditingTools.css
@@ -0,0 +1,397 @@
+#chapters-editor-root {
+ /* Tooltip styles - only on desktop where hover is available */
+ @media (hover: hover) and (pointer: fine) {
+ [data-tooltip] {
+ position: relative;
+ }
+
+ [data-tooltip]:before {
+ content: attr(data-tooltip);
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ margin-bottom: 5px;
+ background-color: rgba(0, 0, 0, 0.8);
+ color: white;
+ text-align: center;
+ padding: 5px 10px;
+ border-radius: 3px;
+ font-size: 12px;
+ white-space: nowrap;
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.2s,
+ visibility 0.2s;
+ z-index: 1000;
+ pointer-events: none;
+ }
+
+ [data-tooltip]:after {
+ content: "";
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border-width: 5px;
+ border-style: solid;
+ border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.2s,
+ visibility 0.2s;
+ pointer-events: none;
+ }
+
+ [data-tooltip]:hover:before,
+ [data-tooltip]:hover:after {
+ opacity: 1;
+ visibility: visible;
+ }
+ }
+
+ /* Hide button tooltips on touch devices */
+ @media (pointer: coarse) {
+ [data-tooltip]:before,
+ [data-tooltip]:after {
+ display: none !important;
+ content: none !important;
+ opacity: 0 !important;
+ visibility: hidden !important;
+ pointer-events: none !important;
+ }
+ }
+
+ .editing-tools-container {
+ background-color: white;
+ border-radius: 0.5rem;
+ padding: 1rem;
+ margin-bottom: 2.5rem;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+ }
+
+ .flex-container {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ position: relative;
+ gap: 15px;
+ width: 100%;
+ }
+
+ .flex-container.single-row {
+ flex-wrap: nowrap;
+ }
+
+ /* Show full text on larger screens, hide short text */
+ .full-text {
+ display: inline;
+ }
+
+ .short-text {
+ display: none;
+ }
+
+ /* Reset text always visible by default */
+ .reset-text {
+ display: inline;
+ }
+
+ .button-group {
+ display: flex;
+ align-items: center;
+
+ &.play-buttons-group {
+ gap: 0.75rem;
+ justify-content: flex-start;
+ flex: 0 0 auto; /* Don't expand to fill space */
+ }
+
+ &.secondary {
+ gap: 0.75rem;
+ align-items: center;
+ justify-content: flex-end;
+ margin-left: auto; /* Push to right edge */
+ }
+
+ button {
+ display: flex;
+ align-items: center;
+ color: #333;
+ background: none;
+ border: none;
+ cursor: pointer;
+ min-width: auto;
+
+ &:hover:not(:disabled) {
+ color: inherit;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ svg {
+ height: 1.25rem;
+ width: 1.25rem;
+ margin-right: 0.25rem;
+ }
+ }
+ }
+
+ .divider {
+ border-right: 1px solid #d1d5db;
+ height: 1.5rem;
+ margin: 0 0.5rem;
+ }
+
+ /* Style for play buttons with highlight effect */
+ .play-button,
+ .preview-button {
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ position: relative;
+ overflow: hidden;
+ min-width: 80px;
+ justify-content: center;
+ font-size: 0.875rem !important;
+ }
+
+ /* Greyed out play button when segments are playing */
+ .play-button.greyed-out {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ /* Highlighted stop button with blue pulse on small screens */
+ .segments-button.highlighted-stop {
+ background-color: rgba(59, 130, 246, 0.1);
+ color: #3b82f6;
+ border: 1px solid #3b82f6;
+ animation: bluePulse 2s infinite;
+ }
+
+ @keyframes bluePulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
+ }
+ 50% {
+ box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
+ }
+ }
+
+ /* Completely disable ALL hover effects for play buttons */
+ .play-button:hover:not(:disabled),
+ .preview-button:hover:not(:disabled) {
+ /* Reset everything to prevent any changes */
+ color: inherit !important;
+ transform: none !important;
+ font-size: 0.875rem !important;
+ width: auto !important;
+ background: none !important;
+ }
+
+ .play-button svg,
+ .preview-button svg {
+ height: 1.5rem;
+ width: 1.5rem;
+ /* Make sure SVG scales with the button but doesn't change layout */
+ flex-shrink: 0;
+ }
+
+ @keyframes pulse {
+ 0% {
+ opacity: 0.8;
+ }
+ 50% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0.8;
+ }
+ }
+
+ /* Add responsive button text class */
+ .button-text {
+ margin-left: 0.25rem;
+ }
+
+ /* Media queries for the editing tools */
+ @media (max-width: 992px) {
+ /* Hide text for undo/redo buttons on medium screens */
+ .button-group.secondary .button-text {
+ display: none;
+ }
+ }
+
+ @media (max-width: 768px) {
+ /* Keep all buttons in a single row, make them more compact */
+ .flex-container.single-row {
+ justify-content: space-between;
+ }
+
+ .button-group {
+ gap: 0.5rem;
+ }
+
+ /* Keep font size consistent regardless of screen size */
+ .preview-button,
+ .play-button {
+ font-size: 0.875rem !important;
+ }
+ }
+
+ @media (max-width: 640px) {
+ /* Prevent container overflow on mobile */
+ .editing-tools-container {
+ padding: 0.75rem;
+ overflow-x: hidden;
+ }
+
+ /* At this breakpoint, make preview button text shorter */
+ .preview-button {
+ min-width: auto;
+ }
+
+ /* Switch to short text versions */
+ .full-text {
+ display: none;
+ }
+
+ .short-text {
+ display: inline;
+ margin-left: 0.15rem;
+ }
+
+ /* Hide reset text */
+ .reset-text {
+ display: none;
+ }
+
+ /* Ensure buttons stay in correct position */
+ .button-group.play-buttons-group {
+ flex: initial;
+ justify-content: flex-start;
+ flex-shrink: 0;
+ }
+
+ .button-group.secondary {
+ flex: initial;
+ justify-content: flex-end;
+ flex-shrink: 0;
+ }
+
+ /* Reduce button sizes on mobile */
+ .button-group button {
+ padding: 0.375rem;
+ min-width: auto;
+ }
+
+ .button-group button svg {
+ height: 1.125rem;
+ width: 1.125rem;
+ margin-right: 0.125rem;
+ }
+ }
+
+ @media (max-width: 576px) {
+ /* Keep single row, left-align play buttons, right-align controls */
+ .flex-container.single-row {
+ justify-content: space-between;
+ flex-wrap: nowrap;
+ gap: 10px;
+ }
+
+ /* Fix left-align for play buttons */
+ .button-group.play-buttons-group {
+ justify-content: flex-start;
+ flex: 0 0 auto;
+ }
+
+ /* Fix right-align for editing controls */
+ .button-group.secondary {
+ justify-content: flex-end;
+ margin-left: auto;
+ }
+
+ /* Reduce button padding to fit more easily */
+ .button-group button {
+ padding: 0.25rem;
+ }
+
+ .divider {
+ margin: 0 0.25rem;
+ }
+ }
+
+ /* Very small screens - maintain layout but reduce further */
+ @media (max-width: 480px) {
+ .editing-tools-container {
+ padding: 0.5rem;
+ }
+
+ .flex-container.single-row {
+ gap: 8px;
+ }
+
+ .button-group.play-buttons-group,
+ .button-group.secondary {
+ gap: 0.25rem;
+ }
+
+ .divider {
+ display: none; /* Hide divider on very small screens */
+ }
+
+ /* Even smaller buttons on very small screens */
+ .button-group button {
+ padding: 0.125rem;
+ }
+
+ .button-group button svg {
+ height: 1rem;
+ width: 1rem;
+ margin-right: 0;
+ }
+
+ /* Hide all button text on very small screens */
+ .button-text,
+ .reset-text {
+ display: none;
+ }
+ }
+
+ /* Portrait orientation specific fixes */
+ @media (max-width: 640px) and (orientation: portrait) {
+ .editing-tools-container {
+ width: 100%;
+ box-sizing: border-box;
+ }
+
+ .flex-container.single-row {
+ width: 100%;
+ padding: 0;
+ margin: 0;
+ }
+
+ /* Ensure button groups don't overflow */
+ .button-group {
+ max-width: 50%;
+ }
+
+ .button-group.play-buttons-group {
+ max-width: 60%;
+ }
+
+ .button-group.secondary {
+ max-width: 40%;
+ }
+ }
+}
diff --git a/frontend-tools/chapters-editor/client/src/styles/IOSNotification.css b/frontend-tools/chapters-editor/client/src/styles/IOSNotification.css
new file mode 100644
index 00000000..bc9cbe39
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/styles/IOSNotification.css
@@ -0,0 +1,167 @@
+.ios-notification {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 1000;
+ background-color: #fffdeb;
+ border-bottom: 1px solid #e2e2e2;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+ padding: 10px;
+ animation: slide-down 0.5s ease-in-out;
+}
+
+@keyframes slide-down {
+ from {
+ transform: translateY(-100%);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+
+.ios-notification-content {
+ max-width: 600px;
+ margin: 0 auto;
+ display: flex;
+ align-items: flex-start;
+ position: relative;
+ padding: 0 10px;
+}
+
+.ios-notification-icon {
+ flex-shrink: 0;
+ color: #0066cc;
+ margin-right: 15px;
+ margin-top: 3px;
+}
+
+.ios-notification-message {
+ flex-grow: 1;
+}
+
+.ios-notification-message h3 {
+ margin: 0 0 5px 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: #000;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+}
+
+.ios-notification-message p {
+ margin: 0 0 8px 0;
+ font-size: 14px;
+ color: #333;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+}
+
+.ios-notification-message ol {
+ margin: 0;
+ padding-left: 20px;
+ font-size: 14px;
+ color: #333;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+}
+
+.ios-notification-message li {
+ margin-bottom: 3px;
+}
+
+.ios-notification-close {
+ position: absolute;
+ top: 0;
+ right: 0;
+ background: none;
+ border: none;
+ color: #666;
+ cursor: pointer;
+ padding: 5px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: color 0.2s;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.ios-notification-close:hover {
+ color: #000;
+}
+
+/* Desktop mode button styling */
+.ios-mode-options {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.ios-desktop-mode-btn {
+ background-color: #0066cc;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ padding: 8px 16px;
+ font-size: 14px;
+ font-weight: 500;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ margin-bottom: 6px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ -webkit-tap-highlight-color: transparent;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.ios-desktop-mode-btn:hover {
+ background-color: #0055aa;
+}
+
+.ios-desktop-mode-btn:active {
+ background-color: #004499;
+ transform: scale(0.98);
+}
+
+.ios-or {
+ font-size: 12px;
+ color: #666;
+ margin: 0 0 6px 0;
+ font-style: italic;
+}
+
+/* iOS-specific styles */
+@supports (-webkit-touch-callout: none) {
+ .ios-notification {
+ padding-top: env(safe-area-inset-top);
+ }
+
+ .ios-notification-close {
+ padding: 10px;
+ }
+}
+
+/* Make sure this notification has better visibility on smaller screens */
+@media (max-width: 480px) {
+ .ios-notification-content {
+ padding: 5px;
+ }
+
+ .ios-notification-message h3 {
+ font-size: 15px;
+ }
+
+ .ios-notification-message p,
+ .ios-notification-message ol {
+ font-size: 13px;
+ }
+}
+
+/* Add iOS-specific styles when in desktop mode */
+html.ios-device {
+ /* Force the content to be rendered at desktop width */
+ min-width: 1024px;
+ overflow-x: auto;
+}
+
+html.ios-device .ios-control-btn {
+ /* Make buttons easier to tap in desktop mode */
+ min-height: 44px;
+}
diff --git a/frontend-tools/chapters-editor/client/src/styles/IOSPlayPrompt.css b/frontend-tools/chapters-editor/client/src/styles/IOSPlayPrompt.css
new file mode 100644
index 00000000..98b74058
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/styles/IOSPlayPrompt.css
@@ -0,0 +1,96 @@
+.mobile-play-prompt-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+ backdrop-filter: blur(5px);
+ -webkit-backdrop-filter: blur(5px);
+}
+
+.mobile-play-prompt {
+ background-color: white;
+ width: 90%;
+ max-width: 400px;
+ border-radius: 12px;
+ padding: 25px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
+ text-align: center;
+}
+
+.mobile-play-prompt h3 {
+ margin: 0 0 15px 0;
+ font-size: 20px;
+ color: #333;
+ font-weight: 600;
+}
+
+.mobile-play-prompt p {
+ margin: 0 0 15px 0;
+ font-size: 16px;
+ color: #444;
+ line-height: 1.5;
+}
+
+.mobile-prompt-instructions {
+ margin: 20px 0;
+ text-align: left;
+ background-color: #f8f9fa;
+ padding: 15px;
+ border-radius: 8px;
+}
+
+.mobile-prompt-instructions p {
+ margin: 0 0 8px 0;
+ font-size: 15px;
+ font-weight: 500;
+}
+
+.mobile-prompt-instructions ol {
+ margin: 0;
+ padding-left: 22px;
+}
+
+.mobile-prompt-instructions li {
+ margin-bottom: 8px;
+ font-size: 14px;
+ color: #333;
+}
+
+.mobile-play-button {
+ background-color: #007bff;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ padding: 12px 25px;
+ font-size: 16px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ margin-top: 5px;
+ /* Make button easier to tap on mobile */
+ min-height: 44px;
+ min-width: 200px;
+}
+
+.mobile-play-button:hover {
+ background-color: #0069d9;
+}
+
+.mobile-play-button:active {
+ background-color: #0062cc;
+ transform: scale(0.98);
+}
+
+/* Special styles for mobile devices */
+@supports (-webkit-touch-callout: none) {
+ .mobile-play-button {
+ /* Extra spacing for mobile */
+ padding: 14px 25px;
+ }
+}
diff --git a/frontend-tools/chapters-editor/client/src/styles/IOSVideoPlayer.css b/frontend-tools/chapters-editor/client/src/styles/IOSVideoPlayer.css
new file mode 100644
index 00000000..fcd7a2ca
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/styles/IOSVideoPlayer.css
@@ -0,0 +1,94 @@
+.ios-video-player-container {
+ position: relative;
+ background-color: #f8f8f8;
+ border: 1px solid #e2e2e2;
+ border-radius: 0.5rem;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ overflow: hidden;
+}
+
+.ios-video-player-container video {
+ width: 100%;
+ height: auto;
+ max-height: 360px;
+ aspect-ratio: 16/9;
+ background-color: black;
+}
+
+.ios-time-display {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ color: #333;
+}
+
+.ios-note {
+ text-align: center;
+ color: #777;
+ font-size: 0.8rem;
+ padding: 0.5rem 0;
+}
+
+/* iOS-specific styling tweaks */
+@supports (-webkit-touch-callout: none) {
+ .ios-video-player-container video {
+ max-height: 50vh; /* Use viewport height on iOS */
+ }
+
+ /* Improve controls visibility on iOS */
+ video::-webkit-media-controls {
+ opacity: 1 !important;
+ visibility: visible !important;
+ }
+
+ /* Ensure controls don't disappear too quickly */
+ video::-webkit-media-controls-panel {
+ transition-duration: 3s !important;
+ }
+}
+
+/* External controls styling */
+.ios-external-controls {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.ios-control-btn {
+ font-weight: bold;
+ min-width: 100px;
+ height: 44px; /* Minimum touch target size for iOS */
+ border: none;
+ border-radius: 8px;
+ transition: all 0.2s ease;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ -webkit-tap-highlight-color: transparent; /* Remove tap highlight on iOS */
+}
+
+.ios-control-btn:active {
+ transform: scale(0.98);
+ opacity: 0.9;
+}
+
+/* Prevent text selection on buttons */
+.no-select {
+ -webkit-touch-callout: none; /* iOS Safari */
+ -webkit-user-select: none; /* Safari */
+ -khtml-user-select: none; /* Konqueror HTML */
+ -moz-user-select: none; /* Firefox */
+ -ms-user-select: none; /* Internet Explorer/Edge */
+ user-select: none; /* Non-prefixed version, supported by Chrome and Opera */
+ cursor: default;
+}
+
+/* Specifically prevent default behavior on fine controls */
+.ios-fine-controls button,
+.ios-external-controls .no-select {
+ touch-action: manipulation;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ pointer-events: auto;
+}
diff --git a/frontend-tools/chapters-editor/client/src/styles/Modal.css b/frontend-tools/chapters-editor/client/src/styles/Modal.css
new file mode 100644
index 00000000..c1aacce7
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/styles/Modal.css
@@ -0,0 +1,306 @@
+#chapters-editor-root {
+ .modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ }
+
+ .modal-container {
+ background-color: white;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ width: 90%;
+ max-width: 500px;
+ max-height: 90vh;
+ overflow-y: auto;
+ animation: modal-fade-in 0.3s ease-out;
+ }
+
+ @keyframes modal-fade-in {
+ from {
+ opacity: 0;
+ transform: translateY(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ .modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 20px;
+ border-bottom: 1px solid #eee;
+ }
+
+ .modal-title {
+ margin: 0;
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #333;
+ }
+
+ .modal-close-button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: #666;
+ padding: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: color 0.2s;
+ }
+
+ .modal-close-button:hover {
+ color: #000;
+ }
+
+ .modal-content {
+ padding: 20px;
+ color: #333;
+ font-size: 1rem;
+ line-height: 1.5;
+ max-height: 400px;
+ overflow-y: auto;
+ }
+
+ .modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ padding: 16px 20px;
+ border-top: 1px solid #eee;
+ gap: 12px;
+ }
+
+ .modal-button {
+ padding: 8px 16px;
+ border-radius: 4px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ border: none;
+ }
+
+ .modal-button-primary {
+ background-color: #0066cc;
+ color: white;
+ }
+
+ .modal-button-primary:hover {
+ background-color: #0055aa;
+ }
+
+ .modal-button-secondary {
+ background-color: #f0f0f0;
+ color: #333;
+ }
+
+ .modal-button-secondary:hover {
+ background-color: #e0e0e0;
+ }
+
+ .modal-button-danger {
+ background-color: #dc3545;
+ color: white;
+ }
+
+ .modal-button-danger:hover {
+ background-color: #bd2130;
+ }
+
+ /* Modal content styles */
+ .modal-message {
+ margin-bottom: 16px;
+ font-size: 1rem;
+ }
+
+ .text-center {
+ text-align: center;
+ }
+
+ .modal-spinner {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 20px 0;
+ }
+
+ .spinner {
+ border: 4px solid rgba(0, 0, 0, 0.1);
+ border-radius: 50%;
+ border-top: 4px solid #0066cc;
+ width: 30px;
+ height: 30px;
+ animation: spin 1s linear infinite;
+ }
+
+ @keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+ }
+
+ .modal-success-icon {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 16px;
+ color: #28a745;
+ font-size: 2rem;
+ }
+
+ .modal-success-icon svg {
+ width: 60px;
+ height: 60px;
+ color: #4caf50;
+ animation: success-pop 0.5s ease-out;
+ }
+
+ @keyframes success-pop {
+ 0% {
+ transform: scale(0);
+ opacity: 0;
+ }
+ 70% {
+ transform: scale(1.1);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+ }
+
+ .modal-error-icon {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 16px;
+ color: #dc3545;
+ font-size: 2rem;
+ }
+
+ .modal-error-icon svg {
+ width: 60px;
+ height: 60px;
+ color: #f44336;
+ animation: error-pop 0.5s ease-out;
+ }
+
+ @keyframes error-pop {
+ 0% {
+ transform: scale(0);
+ opacity: 0;
+ }
+ 70% {
+ transform: scale(1.1);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+ }
+
+ .modal-choices {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 20px;
+ }
+
+ .modal-choice-button {
+ padding: 12px 16px;
+ border: none;
+ border-radius: 4px;
+ background-color: #0066cc;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 500;
+ text-decoration: none;
+ color: white;
+ }
+
+ .modal-choice-button:hover {
+ background-color: #0055aa;
+ transform: translateY(-1px);
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
+ }
+
+ .modal-choice-button svg {
+ margin-right: 8px;
+ }
+
+ .success-link {
+ background-color: #4caf50;
+ }
+
+ .success-link:hover {
+ background-color: #3d8b40;
+ }
+
+ .centered-choice {
+ margin: 0 auto;
+ width: auto;
+ min-width: 220px;
+ background-color: #0066cc;
+ color: white;
+ }
+
+ .centered-choice:hover {
+ background-color: #0055aa;
+ }
+
+ @media (max-width: 480px) {
+ .modal-container {
+ width: 95%;
+ }
+
+ .modal-actions {
+ flex-direction: column;
+ }
+
+ .modal-button {
+ width: 100%;
+ }
+ }
+
+ .error-message {
+ color: #f44336;
+ font-weight: 500;
+ background-color: rgba(244, 67, 54, 0.1);
+ padding: 10px;
+ border-radius: 4px;
+ border-left: 4px solid #f44336;
+ margin-top: 10px;
+ }
+
+ .redirect-message {
+ margin-top: 20px;
+ color: #555;
+ font-size: 0.95rem;
+ padding: 0;
+ margin: 0;
+ }
+
+ .countdown {
+ font-weight: bold;
+ color: #0066cc;
+ font-size: 1.1rem;
+ }
+}
diff --git a/frontend-tools/chapters-editor/client/src/styles/TimelineControls.css b/frontend-tools/chapters-editor/client/src/styles/TimelineControls.css
new file mode 100644
index 00000000..9514fe2a
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/styles/TimelineControls.css
@@ -0,0 +1,1049 @@
+#chapters-editor-root {
+ .timeline-container-card {
+ background-color: white;
+ border-radius: 0.5rem;
+ padding: 1rem;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+ }
+
+ .timeline-header {
+ margin-bottom: 0.75rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .timeline-title {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--foreground, #333);
+ }
+
+ .timeline-title-text {
+ font-weight: 700;
+ }
+
+ .current-time {
+ font-size: 0.875rem;
+ color: var(--foreground, #333);
+ }
+
+ .time-code {
+ font-family: monospace;
+ background-color: #f3f4f6;
+ padding: 0 0.5rem;
+ border-radius: 0.25rem;
+ }
+
+ .duration-time {
+ font-size: 0.875rem;
+ color: var(--foreground, #333);
+ }
+
+ .timeline-scroll-container {
+ position: relative;
+ overflow: visible !important;
+ }
+
+ .timeline-container {
+ position: relative;
+ min-width: 100%;
+ background-color: #fafbfc;
+ height: 70px;
+ border-radius: 0.25rem;
+ overflow: visible !important;
+ }
+
+ .timeline-marker {
+ position: absolute;
+ height: 82px; /* Increased height to extend below timeline */
+ width: 2px;
+ background-color: #000;
+ transform: translateX(-50%);
+ z-index: 50;
+ pointer-events: none;
+ }
+
+ .timeline-marker-head {
+ position: absolute;
+ top: -6px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 16px;
+ height: 16px;
+ background-color: #ef4444;
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: auto;
+ z-index: 51;
+ }
+
+ .timeline-marker-drag {
+ position: absolute;
+ bottom: -12px; /* Changed from -6px to -12px to move it further down */
+ left: 50%;
+ transform: translateX(-50%);
+ width: 16px;
+ height: 16px;
+ background-color: #4b5563;
+ border-radius: 50%;
+ cursor: grab;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: auto;
+ z-index: 51;
+ }
+
+ .timeline-marker-drag.dragging {
+ cursor: grabbing;
+ background-color: #374151;
+ }
+
+ .timeline-marker-head-icon {
+ color: white;
+ font-size: 14px;
+ font-weight: bold;
+ line-height: 1;
+ user-select: none;
+ }
+
+ .timeline-marker-drag-icon {
+ color: white;
+ font-size: 12px;
+ line-height: 1;
+ user-select: none;
+ transform: rotate(90deg);
+ display: inline-block;
+ }
+
+ .trim-line-marker {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 20;
+ }
+
+ .trim-handle {
+ position: absolute;
+ width: 10px;
+ height: 20px;
+ background-color: black;
+ cursor: ew-resize;
+
+ &.left {
+ right: 0;
+ top: 10px;
+ border-radius: 3px 0 0 3px;
+ }
+
+ &.right {
+ left: 0;
+ top: 10px;
+ border-radius: 0 3px 3px 0;
+ }
+ }
+
+ .timeline-thumbnail {
+ display: inline-block;
+ height: 70px;
+ border-right: 1px solid rgba(0, 0, 0, 0.03);
+ }
+
+ .split-point {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background-color: rgba(255, 0, 0, 0.5);
+ z-index: 15;
+ }
+
+ .clip-segment {
+ position: absolute;
+ height: 70px;
+ border-radius: 4px;
+ z-index: 10;
+ border: 2px solid rgba(0, 0, 0, 0.15);
+ cursor: pointer;
+
+ &:hover {
+ box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.3);
+ border-color: rgba(0, 0, 0, 0.4);
+ background-color: rgba(240, 240, 240, 0.8) !important;
+ }
+
+ &.selected {
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.7);
+ border-color: rgba(59, 130, 246, 0.9);
+ }
+
+ &.selected:hover {
+ background-color: rgba(240, 248, 255, 0.85) !important;
+ }
+ }
+
+ .clip-segment-info {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 0.4rem;
+ background-color: rgba(0, 0, 0, 0.4);
+ color: white;
+ opacity: 1;
+ transition: background-color 0.2s;
+ line-height: 1.3;
+ }
+
+ .clip-segment:hover .clip-segment-info {
+ background-color: rgba(0, 0, 0, 0.5);
+ }
+
+ .clip-segment.selected .clip-segment-info {
+ background-color: rgba(59, 130, 246, 0.5);
+ }
+
+ .clip-segment.selected:hover .clip-segment-info {
+ background-color: rgba(59, 130, 246, 0.4);
+ }
+
+ .clip-segment-name {
+ font-weight: 700;
+ font-size: 12px;
+ }
+
+ .clip-segment-time {
+ font-size: 10px;
+ }
+
+ .clip-segment-duration {
+ font-size: 10px;
+ }
+
+ .clip-segment-handle {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 6px;
+ background-color: rgba(0, 0, 0, 0.2);
+ cursor: ew-resize;
+ }
+
+ .clip-segment-handle:hover {
+ background-color: rgba(0, 0, 0, 0.4);
+ }
+
+ .clip-segment-handle.left {
+ left: 0;
+ border-radius: 2px 0 0 2px;
+ }
+
+ .clip-segment-handle.right {
+ right: 0;
+ border-radius: 0 2px 2px 0;
+ }
+
+ /* Enhanced handles for touch devices */
+ @media (pointer: coarse) {
+ .clip-segment-handle {
+ width: 14px; /* Wider target for touch devices */
+ background-color: rgba(0, 0, 0, 0.4); /* Darker by default for better visibility */
+ }
+
+ .clip-segment-handle:after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 2px;
+ height: 20px;
+ background-color: rgba(255, 255, 255, 0.8);
+ border-radius: 1px;
+ }
+
+ .clip-segment-handle.left:after {
+ box-shadow: -2px 0 0 rgba(0, 0, 0, 0.5);
+ }
+
+ .clip-segment-handle.right:after {
+ box-shadow: 2px 0 0 rgba(0, 0, 0, 0.5);
+ }
+
+ /* Active state for touch feedback */
+ .clip-segment-handle:active {
+ background-color: rgba(0, 0, 0, 0.6);
+ }
+
+ .timeline-marker {
+ height: 85px;
+ }
+
+ .timeline-marker-head {
+ width: 24px;
+ height: 24px;
+ top: -13px;
+ }
+
+ .timeline-marker-drag {
+ width: 24px;
+ height: 24px;
+ bottom: -18px;
+ }
+
+ .timeline-marker-head.dragging {
+ width: 28px;
+ height: 28px;
+ top: -15px;
+ }
+ }
+
+ .segment-tooltip,
+ .empty-space-tooltip {
+ position: absolute;
+ background-color: white;
+ border-radius: 4px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ padding: 0.5rem;
+ z-index: 1000;
+ min-width: 150px;
+ text-align: center;
+ pointer-events: auto;
+ top: -105px !important;
+ transform: translateY(-10px);
+ }
+
+ .segment-tooltip {
+ top: -165px !important;
+ }
+
+ .segment-tooltip:after,
+ .empty-space-tooltip:after {
+ content: "";
+ position: absolute;
+ bottom: -5px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0;
+ height: 0;
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-top: 5px solid white;
+ }
+
+ .segment-tooltip:before,
+ .empty-space-tooltip:before {
+ content: "";
+ position: absolute;
+ bottom: -6px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0;
+ height: 0;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-top: 6px solid rgba(0, 0, 0, 0.1);
+ z-index: -1;
+ }
+
+ .tooltip-time {
+ font-weight: 600;
+ font-size: 0.875rem;
+ margin-bottom: 0.5rem;
+ color: #333;
+ }
+
+ .tooltip-actions {
+ display: flex;
+ justify-content: center;
+ gap: 0.5rem;
+ }
+
+ .tooltip-action-btn {
+ background-color: #f3f4f6;
+ border: none;
+ border-radius: 0.25rem;
+ padding: 0.375rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ color: #4b5563;
+ min-width: 20px !important;
+ }
+
+ .tooltip-action-btn:hover {
+ background-color: #e5e7eb;
+ color: #111827;
+ }
+
+ .tooltip-action-btn.delete {
+ color: #ef4444;
+ }
+
+ .tooltip-action-btn.delete:hover {
+ background-color: #fee2e2;
+ }
+
+ .tooltip-action-btn.new-segment {
+ padding: 0.375rem 0.5rem;
+ }
+
+ .tooltip-action-btn.new-segment .tooltip-btn-text {
+ margin-left: 0.25rem;
+ font-size: 0.75rem;
+ }
+
+ .tooltip-action-btn svg {
+ width: 1rem;
+ height: 1rem;
+ }
+
+ .timeline-controls {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 0.75rem;
+ }
+
+ .time-navigation {
+ display: none;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ .time-nav-label {
+ font-size: 0.875rem;
+ font-weight: 500;
+ }
+
+ .time-input {
+ border: 1px solid #d1d5db;
+ border-radius: 0.25rem;
+ padding: 0.25rem 0.5rem;
+ width: 8rem;
+ font-size: 0.875rem;
+ }
+
+ .time-button-group {
+ display: flex;
+ }
+
+ .time-button {
+ background-color: #e5e7eb;
+ color: black;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.875rem;
+ border: none;
+ cursor: pointer;
+ margin-right: 0.5rem;
+ }
+
+ .time-button:hover {
+ background-color: #d1d5db;
+ }
+
+ .time-button:first-child {
+ border-top-left-radius: 0.25rem;
+ border-bottom-left-radius: 0.25rem;
+ }
+
+ .time-button:last-child {
+ border-top-right-radius: 0.25rem;
+ border-bottom-right-radius: 0.25rem;
+ }
+
+ .controls-right {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-left: auto;
+ }
+
+ .zoom-dropdown-container {
+ position: relative;
+ z-index: 100;
+ display: none;
+ }
+
+ .zoom-button {
+ background-color: #374151;
+ color: white;
+ border: none;
+ border-radius: 0.25rem;
+ padding: 0.25rem 0.75rem;
+ font-size: 0.875rem;
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ }
+
+ .zoom-button:hover {
+ background-color: #1f2937;
+ }
+
+ .zoom-button svg {
+ margin-left: 0.25rem;
+ }
+
+ .zoom-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ margin-top: 0.25rem;
+ width: 9rem;
+ background-color: #374151;
+ color: white;
+ border-radius: 0.25rem;
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+ z-index: 50;
+ max-height: 300px;
+ overflow-y: auto;
+ }
+
+ .zoom-option {
+ padding: 0.25rem 0.75rem;
+ cursor: pointer;
+ }
+
+ .zoom-option:hover {
+ background-color: #4b5563;
+ }
+
+ .zoom-option.selected {
+ background-color: #6b7280;
+ display: flex;
+ align-items: center;
+ }
+
+ .zoom-option svg {
+ margin-right: 0.25rem;
+ }
+
+ /* Save buttons container */
+ .save-buttons-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0;
+ flex-wrap: nowrap;
+ }
+
+ /* General styles for all save buttons */
+ .save-button,
+ .save-copy-button,
+ .save-segments-button {
+ color: #ffffff;
+ background: #0066cc;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+ padding: 0.25rem 0.5rem;
+ cursor: pointer;
+ border: none;
+ white-space: nowrap;
+ transition: background-color 0.2s;
+ min-width: fit-content;
+ }
+
+ /* Shared hover effect */
+ .save-button:hover,
+ .save-copy-button:hover,
+ .save-segments-button:hover {
+ background-color: #0056b3;
+ }
+
+ /* Media query for smaller screens */
+ @media (max-width: 576px) {
+ .save-buttons-row {
+ width: 100%;
+ justify-content: space-between;
+ gap: 0.5rem;
+ }
+
+ .save-button,
+ .save-copy-button,
+ .save-segments-button {
+ flex: 1;
+ font-size: 0.7rem;
+ padding: 0.25rem 0.35rem;
+ }
+ }
+
+ /* Very small screens - adjust save buttons */
+ @media (max-width: 480px) {
+ .save-button,
+ .save-copy-button,
+ .save-segments-button {
+ font-size: 0.675rem;
+ padding: 0.25rem;
+ }
+
+ /* Remove margins for controls-right buttons */
+ .controls-right {
+ margin: 0;
+ }
+
+ .controls-right button {
+ margin: 0;
+ }
+ }
+
+ /* Tooltip styles - only on desktop where hover is available */
+ @media (hover: hover) and (pointer: fine) {
+ [data-tooltip] {
+ position: relative;
+ }
+
+ [data-tooltip]:before {
+ content: attr(data-tooltip);
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ margin-bottom: 5px;
+ background-color: rgba(0, 0, 0, 0.8);
+ color: white;
+ text-align: center;
+ padding: 5px 10px;
+ border-radius: 3px;
+ font-size: 12px;
+ white-space: nowrap;
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.2s,
+ visibility 0.2s;
+ z-index: 1000;
+ pointer-events: none;
+ }
+
+ [data-tooltip]:after {
+ content: "";
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border-width: 5px;
+ border-style: solid;
+ border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.2s,
+ visibility 0.2s;
+ pointer-events: none;
+ }
+
+ [data-tooltip]:hover:before,
+ [data-tooltip]:hover:after {
+ opacity: 1;
+ visibility: visible;
+ }
+ }
+
+ /* Hide button tooltips on touch devices */
+ @media (pointer: coarse) {
+ [data-tooltip]:before,
+ [data-tooltip]:after {
+ display: none !important;
+ content: none !important;
+ opacity: 0 !important;
+ visibility: hidden !important;
+ pointer-events: none !important;
+ }
+ }
+
+ /* Modal success and error styling */
+ .modal-success-content,
+ .modal-error-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 1rem;
+ text-align: center;
+ padding: 0;
+ margin: 0;
+ }
+
+ .modal-success-icon,
+ .modal-error-icon {
+ margin-bottom: 1rem;
+ }
+
+ .modal-success-icon svg {
+ color: #4caf50;
+ animation: fadeIn 0.5s ease-in-out;
+ }
+
+ .modal-error-icon svg {
+ color: #f44336;
+ animation: fadeIn 0.5s ease-in-out;
+ }
+
+ .success-link {
+ background-color: #4caf50;
+ color: white;
+ transition: background-color 0.3s;
+ }
+
+ .success-link:hover {
+ background-color: #388e3c;
+ }
+
+ .error-message {
+ color: #f44336;
+ font-weight: 500;
+ }
+
+ /* Modal spinner animation */
+ .modal-spinner {
+ display: flex;
+ justify-content: center;
+ margin: 2rem 0;
+ }
+
+ .spinner {
+ width: 50px;
+ height: 50px;
+ border: 5px solid rgba(0, 0, 0, 0.1);
+ border-radius: 50%;
+ border-top-color: #0066cc;
+ animation: spin 1s ease-in-out infinite;
+ }
+
+ @keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+ }
+
+ .auto-save-spinner {
+ animation: spin 1s linear infinite;
+ }
+
+ @keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: scale(0.9);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+
+ /* Centered modal content */
+ .text-center {
+ text-align: center;
+ }
+
+ .modal-message {
+ margin-bottom: 1rem;
+ line-height: 1.5;
+ }
+
+ .modal-choice-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.75rem 1.25rem;
+ background-color: #0066cc;
+ color: white;
+ border-radius: 4px;
+ text-decoration: none;
+ margin: 0 auto;
+ cursor: pointer;
+ font-weight: 500;
+ gap: 0.5rem;
+ border: none;
+ transition: background-color 0.3s;
+ }
+
+ .modal-choice-button:hover {
+ background-color: #0056b3;
+ }
+
+ .modal-choice-button svg {
+ flex-shrink: 0;
+ }
+
+ .centered-choice {
+ margin: 0 auto;
+ min-width: 180px;
+ }
+}
+
+/* Mobile Timeline Overlay */
+.mobile-timeline-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 50;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 0.5rem;
+ pointer-events: none; /* Allow clicks to pass through */
+}
+
+.mobile-timeline-message {
+ background-color: rgba(0, 0, 0, 0.8);
+ border-radius: 8px;
+ padding: 15px 25px;
+ text-align: center;
+ max-width: 80%;
+ animation: pulse 2s infinite;
+}
+
+.mobile-timeline-message p {
+ color: white;
+ font-size: 16px;
+ margin: 0 0 15px 0;
+ font-weight: 500;
+}
+
+.mobile-play-icon {
+ width: 0;
+ height: 0;
+ border-top: 15px solid transparent;
+ border-bottom: 15px solid transparent;
+ border-left: 25px solid white;
+ margin: 0 auto;
+}
+
+@keyframes pulse {
+ 0% {
+ opacity: 0.7;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1.05);
+ }
+ 100% {
+ opacity: 0.7;
+ transform: scale(1);
+ }
+}
+
+/* Segments playback mode styles - minimal functional styling */
+.segments-playback-mode .tooltip-time-btn {
+ opacity: 1;
+ cursor: pointer;
+}
+
+.segments-playback-mode .tooltip-action-btn.play,
+.segments-playback-mode .tooltip-action-btn.pause {
+ opacity: 1;
+ cursor: pointer;
+}
+
+/* During segments playback mode, disable button interactions but keep hover working */
+.segments-playback-mode .tooltip-time-btn[disabled],
+.segments-playback-mode .tooltip-action-btn[disabled] {
+ opacity: 0.5 !important;
+ cursor: not-allowed !important;
+}
+
+/* Ensure disabled buttons still show tooltips on hover */
+.segments-playback-mode [data-tooltip][disabled]:hover:before,
+.segments-playback-mode [data-tooltip][disabled]:hover:after {
+ opacity: 1 !important;
+ visibility: visible !important;
+}
+
+/* Show segments playback message */
+.segments-playback-message {
+ display: flex;
+ align-items: center;
+ background-color: rgba(59, 130, 246, 0.1);
+ color: #3b82f6;
+ padding: 6px 12px;
+ border-radius: 4px;
+ font-weight: 600;
+ font-size: 0.875rem;
+ animation: pulse 2s infinite;
+}
+
+.segments-playback-message svg {
+ height: 1.25rem;
+ width: 1.25rem;
+ margin-right: 0.5rem;
+ color: #3b82f6;
+}
+
+/* Chapter Editor Styles */
+.chapter-editor {
+ background-color: #f8fafc;
+ border: 2px solid #3b82f6;
+ border-radius: 0.5rem;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ transition: all 0.2s ease;
+}
+
+.chapter-editor-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ margin-bottom: 0.75rem;
+ gap: 1rem;
+}
+
+.chapter-editor-title-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ flex: 1;
+}
+
+.chapter-editor-header h4 {
+ margin: 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: #1f2937;
+}
+
+.chapter-editor-segment {
+ font-size: 0.75rem;
+ color: #6b7280;
+ background-color: #e5e7eb;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ display: inline-block;
+}
+
+.save-chapters-button {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background-color: #3b82f6;
+ color: white;
+ border: none;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.save-chapters-button:hover {
+ background-color: #2563eb;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
+}
+
+.save-chapters-button.has-changes {
+ background-color: #10b981;
+ animation: pulse-green-chapters 2s infinite;
+}
+
+.save-chapters-button.has-changes:hover {
+ background-color: #059669;
+}
+
+.save-chapters-button svg {
+ width: 1rem;
+ height: 1rem;
+}
+
+@keyframes pulse-green-chapters {
+ 0%,
+ 100% {
+ background-color: #10b981;
+ }
+ 50% {
+ background-color: #34d399;
+ }
+}
+
+.chapter-title-input {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ resize: vertical;
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+}
+
+.chapter-title-input:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.chapter-title-input::placeholder {
+ color: #9ca3af;
+}
+
+.chapter-editor-info {
+ margin-top: 0.5rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+ font-style: italic;
+}
+
+/* Chapter Editor Responsive styles */
+@media (max-width: 768px) {
+ .chapter-editor-header {
+ flex-direction: column;
+ gap: 0.75rem;
+ align-items: stretch;
+ }
+
+ .save-chapters-button {
+ justify-content: center;
+ align-self: stretch;
+ }
+
+ .chapter-editor-segment {
+ text-align: center;
+ }
+}
+
+/* Tooltip Chapter Editor Styles */
+.tooltip-chapter-editor {
+ background-color: rgba(255, 255, 255, 0.95);
+ border-radius: 0.375rem;
+ pointer-events: auto; /* Ensure it can receive clicks */
+}
+
+textarea.tooltip-chapter-input {
+ width: 100%;
+ padding: 0.5rem;
+ border: 2px solid #ccc;
+ border-radius: 0.25rem;
+ background-color: white;
+ color: black;
+ font-size: 0.75rem;
+ resize: none;
+ outline: none;
+ box-sizing: border-box;
+ height: 55px !important;
+ max-height: 55px !important;
+ min-height: 55px !important;
+}
+
+textarea.tooltip-chapter-input:focus {
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
+}
+
+textarea.tooltip-chapter-input::placeholder {
+ color: #999;
+}
diff --git a/frontend-tools/chapters-editor/client/src/styles/TwoRowTooltip.css b/frontend-tools/chapters-editor/client/src/styles/TwoRowTooltip.css
new file mode 100644
index 00000000..bc51a8dd
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/styles/TwoRowTooltip.css
@@ -0,0 +1,341 @@
+.two-row-tooltip {
+ display: flex;
+ flex-direction: column;
+ background-color: white;
+ padding: 6px;
+ border-radius: 4px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ position: relative;
+ z-index: 3000; /* Highest z-index to ensure it's above all other elements */
+}
+
+/* Hide ±100ms buttons for more compact tooltip */
+.tooltip-time-btn[data-tooltip="Decrease by 100ms"],
+.tooltip-time-btn[data-tooltip="Increase by 100ms"] {
+ display: none !important;
+}
+
+.tooltip-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 3px;
+}
+
+.tooltip-row:first-child {
+ margin-bottom: 6px;
+}
+
+.tooltip-time-btn {
+ background-color: #f0f0f0 !important;
+ border: none !important;
+ border-radius: 4px !important;
+ padding: 4px 8px !important;
+ font-size: 0.75rem !important;
+ font-weight: 500 !important;
+ color: #333 !important;
+ cursor: pointer !important;
+ transition: background-color 0.2s !important;
+ min-width: 20px !important;
+}
+
+.tooltip-time-btn:hover {
+ background-color: #e0e0e0 !important;
+}
+
+.tooltip-time-display {
+ font-family: monospace !important;
+ font-size: 0.875rem !important;
+ font-weight: 600 !important;
+ color: #333 !important;
+ padding: 4px 6px !important;
+ background-color: #f7f7f7 !important;
+ border-radius: 4px !important;
+ min-width: 100px !important;
+ text-align: center !important;
+ overflow: hidden !important;
+}
+
+/* Disabled state for time display */
+.tooltip-time-display.disabled {
+ pointer-events: none !important;
+ cursor: not-allowed !important;
+ opacity: 0.6 !important;
+ user-select: none !important;
+ -webkit-user-select: none !important;
+ -moz-user-select: none !important;
+ -ms-user-select: none !important;
+}
+
+/* Force disabled tooltips to show on hover for better user feedback */
+.tooltip-time-btn.disabled[data-tooltip]:hover:before,
+.tooltip-time-btn.disabled[data-tooltip]:hover:after,
+.tooltip-action-btn.disabled[data-tooltip]:hover:before,
+.tooltip-action-btn.disabled[data-tooltip]:hover:after {
+ opacity: 1 !important;
+ visibility: visible !important;
+}
+
+.tooltip-actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 3px;
+ position: relative;
+ z-index: 2500; /* Higher z-index to ensure buttons appear above other elements */
+ margin-top: 5px;
+}
+
+.tooltip-action-btn {
+ background-color: #f3f4f6;
+ border: none;
+ border-radius: 4px;
+ padding: 5px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ color: #4b5563;
+ width: 26px;
+ height: 26px;
+ min-width: 20px !important;
+ position: relative; /* Add relative positioning for tooltips */
+}
+
+/* Custom tooltip styles for second row action buttons - positioned below */
+.tooltip-action-btn[data-tooltip]:before {
+ content: attr(data-tooltip);
+ position: absolute;
+ height: 30px;
+ top: 35px; /* Position below the button with increased space */
+ left: 50%; /* Center horizontally */
+ transform: translateX(-50%); /* Center horizontally */
+ margin-left: 0; /* Reset margin */
+ background-color: rgba(0, 0, 0, 0.85);
+ color: white;
+ text-align: left;
+ padding: 6px 12px;
+ border-radius: 4px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+ font-size: 12px;
+ white-space: nowrap;
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.2s,
+ visibility 0.2s;
+ z-index: 2500; /* High z-index */
+ pointer-events: none;
+}
+
+/* Triangle arrow pointing up to the button */
+.tooltip-action-btn[data-tooltip]:after {
+ content: "";
+ position: absolute;
+ top: 35px; /* Match the before element */
+ left: 50%; /* Center horizontally */
+ transform: translateX(-50%); /* Center horizontally */
+ border-width: 4px;
+ border-style: solid;
+ /* Arrow pointing down from button to tooltip */
+ border-color: rgba(0, 0, 0, 0.85) transparent transparent transparent;
+ margin-left: 0; /* Reset margin */
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.2s,
+ visibility 0.2s;
+ z-index: 2500; /* High z-index */
+ pointer-events: none;
+}
+
+/* Show tooltips on hover - but only on devices with hover capability (desktops) */
+@media (hover: hover) and (pointer: fine) {
+ .tooltip-action-btn[data-tooltip]:hover:before,
+ .tooltip-action-btn[data-tooltip]:hover:after {
+ opacity: 1;
+ visibility: visible;
+ }
+}
+
+/* Keep the two-row-tooltip visible but hide button attribute tooltips on touch devices */
+@media (pointer: coarse) {
+ .tooltip-action-btn[data-tooltip]:before,
+ .tooltip-action-btn[data-tooltip]:after {
+ display: none !important;
+ opacity: 0 !important;
+ visibility: hidden !important;
+ pointer-events: none !important;
+ content: none !important;
+ }
+}
+
+.tooltip-action-btn:hover {
+ background-color: #e5e7eb;
+ color: #111827;
+}
+
+.tooltip-action-btn.delete {
+ color: #ef4444;
+}
+
+.tooltip-action-btn.delete:hover {
+ background-color: #fee2e2;
+}
+
+.tooltip-action-btn.play {
+ color: #10b981;
+}
+
+.tooltip-action-btn.play:hover {
+ background-color: #d1fae5;
+}
+
+.tooltip-action-btn.pause {
+ color: #3b82f6;
+}
+
+.tooltip-action-btn.pause:hover {
+ background-color: #dbeafe;
+}
+
+.tooltip-action-btn.play-from-start {
+ color: #4f46e5;
+}
+
+.tooltip-action-btn.play-from-start:hover {
+ background-color: #e0e7ff;
+}
+
+.tooltip-action-btn svg {
+ width: 16px;
+ height: 16px;
+}
+
+/* Adjust the new segment button style */
+.tooltip-action-btn.new-segment {
+ width: auto;
+ height: auto;
+ padding: 6px 10px;
+ display: flex;
+ flex-direction: row;
+ color: #10b981;
+}
+
+.tooltip-action-btn.new-segment:hover {
+ background-color: #d1fae5;
+}
+
+.tooltip-action-btn.new-segment .tooltip-btn-text {
+ margin-left: 6px;
+ font-size: 0.75rem;
+ white-space: nowrap;
+}
+
+/* Disabled state for tooltip action buttons */
+.tooltip-action-btn.disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ background-color: #f3f4f6;
+}
+
+.tooltip-action-btn.disabled:hover {
+ background-color: #f3f4f6;
+ color: #9ca3af;
+}
+
+.tooltip-action-btn.disabled svg {
+ color: #9ca3af;
+}
+
+.tooltip-action-btn.disabled .tooltip-btn-text {
+ color: #9ca3af;
+}
+
+/* Ensure pause button is properly styled when disabled */
+.tooltip-action-btn.pause.disabled {
+ color: #9ca3af !important;
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.tooltip-action-btn.pause.disabled:hover {
+ background-color: #f3f4f6 !important;
+ color: #9ca3af !important;
+}
+
+/* Ensure play button is properly styled when disabled */
+.tooltip-action-btn.play.disabled {
+ color: #9ca3af !important;
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.tooltip-action-btn.play.disabled:hover {
+ background-color: #f3f4f6 !important;
+ color: #9ca3af !important;
+}
+
+/* Ensure time adjustment buttons are properly styled when disabled */
+.tooltip-time-btn.disabled {
+ opacity: 0.5 !important;
+ cursor: not-allowed !important;
+ background-color: #f3f4f6 !important;
+ color: #9ca3af !important;
+}
+
+.tooltip-time-btn.disabled:hover {
+ background-color: #f3f4f6 !important;
+ color: #9ca3af !important;
+}
+
+/* Additional mobile optimizations */
+@media (max-width: 768px) {
+ .two-row-tooltip {
+ padding: 4px;
+ }
+
+ .tooltip-row:first-child {
+ margin-bottom: 4px;
+ }
+
+ .tooltip-time-btn {
+ min-width: 20px !important;
+ font-size: 0.7rem !important;
+ padding: 3px 6px !important;
+ }
+
+ .tooltip-time-display {
+ font-size: 0.8rem !important;
+ padding: 3px 4px !important;
+ min-width: 90px !important;
+ }
+
+ .tooltip-action-btn {
+ width: 24px;
+ height: 24px;
+ padding: 4px;
+ }
+
+ .tooltip-action-btn.new-segment {
+ padding: 4px 8px;
+ }
+
+ .tooltip-action-btn svg {
+ width: 14px;
+ height: 14px;
+ }
+
+ /* Adjust tooltip position for small screens - maintain the same position but adjust size */
+ .tooltip-action-btn[data-tooltip]:before {
+ min-width: 100px;
+ font-size: 11px;
+ padding: 4px 8px;
+ height: 24px;
+ top: 33px; /* Maintain the same relative distance on mobile */
+ }
+
+ .tooltip-action-btn[data-tooltip]:after {
+ top: 33px; /* Match the tooltip position */
+ }
+}
diff --git a/frontend-tools/chapters-editor/client/src/styles/VideoPlayer.css b/frontend-tools/chapters-editor/client/src/styles/VideoPlayer.css
new file mode 100644
index 00000000..c0897c7f
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/styles/VideoPlayer.css
@@ -0,0 +1,342 @@
+#chapters-editor-root {
+ /* Tooltip styles - only on desktop where hover is available */
+ @media (hover: hover) and (pointer: fine) {
+ [data-tooltip] {
+ position: relative;
+ }
+
+ [data-tooltip]:before {
+ content: attr(data-tooltip);
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ margin-bottom: 5px;
+ background-color: rgba(0, 0, 0, 0.8);
+ color: white;
+ text-align: center;
+ padding: 5px 10px;
+ border-radius: 3px;
+ font-size: 12px;
+ white-space: nowrap;
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.2s,
+ visibility 0.2s;
+ z-index: 1000;
+ pointer-events: none;
+ }
+
+ [data-tooltip]:after {
+ content: "";
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border-width: 5px;
+ border-style: solid;
+ border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.2s,
+ visibility 0.2s;
+ pointer-events: none;
+ }
+
+ [data-tooltip]:hover:before,
+ [data-tooltip]:hover:after {
+ opacity: 1;
+ visibility: visible;
+ }
+ }
+
+ /* Hide button tooltips on touch devices */
+ @media (pointer: coarse) {
+ [data-tooltip]:before,
+ [data-tooltip]:after {
+ display: none !important;
+ content: none !important;
+ opacity: 0 !important;
+ visibility: hidden !important;
+ pointer-events: none !important;
+ }
+ }
+ .video-player-container {
+ position: relative;
+ width: 100%;
+ background: #000;
+ border-radius: 0.5rem;
+ overflow: hidden;
+ margin-bottom: 1rem;
+ aspect-ratio: 16/9;
+ /* Prevent iOS Safari from showing default video controls */
+ -webkit-user-select: none;
+ user-select: none;
+ }
+
+ .video-player-container video {
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
+ /* Force hardware acceleration */
+ transform: translateZ(0);
+ -webkit-transform: translateZ(0);
+ /* Prevent iOS Safari from showing default video controls */
+ -webkit-user-select: none;
+ user-select: none;
+ }
+
+ /* iOS-specific styles */
+ @supports (-webkit-touch-callout: none) {
+ .video-player-container video {
+ /* Additional iOS optimizations */
+ -webkit-tap-highlight-color: transparent;
+ -webkit-touch-callout: none;
+ }
+ }
+
+ .play-pause-indicator {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 60px;
+ height: 60px;
+ background-color: rgba(0, 0, 0, 0.6);
+ border-radius: 50%;
+ opacity: 0;
+ transition: opacity 0.3s;
+ pointer-events: none;
+ }
+
+ .video-player-container:hover .play-pause-indicator {
+ opacity: 1;
+ }
+
+ .play-pause-indicator::before {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ }
+
+ .play-pause-indicator.play-icon::before {
+ width: 0;
+ height: 0;
+ border-top: 15px solid transparent;
+ border-bottom: 15px solid transparent;
+ border-left: 25px solid white;
+ margin-left: 3px;
+ }
+
+ .play-pause-indicator.pause-icon::before {
+ width: 20px;
+ height: 25px;
+ border-left: 6px solid white;
+ border-right: 6px solid white;
+ }
+
+ /* iOS First-play indicator */
+ .ios-first-play-indicator {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10;
+ }
+
+ .ios-play-message {
+ color: white;
+ font-size: 1.2rem;
+ text-align: center;
+ padding: 1rem;
+ background: rgba(0, 0, 0, 0.8);
+ border-radius: 0.5rem;
+ animation: pulse 2s infinite;
+ }
+
+ @keyframes pulse {
+ 0% {
+ opacity: 0.7;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1.05);
+ }
+ 100% {
+ opacity: 0.7;
+ transform: scale(1);
+ }
+ }
+
+ .video-controls {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 0.75rem;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
+ opacity: 0;
+ transition: opacity 0.3s;
+ }
+
+ .video-player-container:hover .video-controls {
+ opacity: 1;
+ }
+
+ .video-current-time {
+ color: white;
+ font-size: 0.875rem;
+ }
+
+ .video-duration {
+ color: white;
+ font-size: 0.875rem;
+ }
+
+ .video-time-display {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 0.5rem;
+ color: white;
+ font-size: 0.875rem;
+ }
+
+ .video-progress {
+ position: relative;
+ height: 6px;
+ background-color: rgba(255, 255, 255, 0.3);
+ border-radius: 3px;
+ cursor: pointer;
+ margin: 0 10px;
+ touch-action: none; /* Prevent browser handling of drag gestures */
+ flex-grow: 1;
+ }
+
+ .video-progress.dragging {
+ height: 8px;
+ }
+
+ .video-progress-fill {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ background-color: #ff0000;
+ border-radius: 3px;
+ pointer-events: none;
+ }
+
+ .video-scrubber {
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 16px;
+ height: 16px;
+ background-color: #ff0000;
+ border-radius: 50%;
+ cursor: grab;
+ transition:
+ transform 0.1s ease,
+ width 0.1s ease,
+ height 0.1s ease;
+ }
+
+ /* Make the scrubber larger when dragging for better control */
+ .video-progress.dragging .video-scrubber {
+ transform: translate(-50%, -50%) scale(1.2);
+ width: 18px;
+ height: 18px;
+ cursor: grabbing;
+ box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
+ }
+
+ /* Enhance for touch devices */
+ @media (pointer: coarse) {
+ .video-scrubber {
+ width: 20px;
+ height: 20px;
+ }
+
+ .video-progress.dragging .video-scrubber {
+ width: 24px;
+ height: 24px;
+ }
+
+ /* Create a larger invisible touch target */
+ .video-scrubber:before {
+ content: "";
+ position: absolute;
+ top: -10px;
+ left: -10px;
+ right: -10px;
+ bottom: -10px;
+ }
+ }
+
+ .video-controls-buttons {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ }
+
+ .mute-button,
+ .fullscreen-button {
+ min-width: auto;
+ color: white;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0.25rem;
+ transition: transform 0.2s;
+
+ &:hover {
+ transform: scale(1.1);
+ }
+
+ svg {
+ width: 1.25rem;
+ height: 1.25rem;
+ }
+ }
+
+ /* Time tooltip that appears when dragging */
+ .video-time-tooltip {
+ position: absolute;
+ top: -30px;
+ background-color: rgba(0, 0, 0, 0.7);
+ color: white;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-family: monospace;
+ pointer-events: none;
+ z-index: 1000;
+ white-space: nowrap;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
+ }
+
+ /* Add a small arrow to the tooltip */
+ .video-time-tooltip:after {
+ content: "";
+ position: absolute;
+ bottom: -4px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0;
+ height: 0;
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-top: 4px solid rgba(0, 0, 0, 0.7);
+ }
+}
diff --git a/frontend-tools/chapters-editor/client/src/vite-env.d.ts b/frontend-tools/chapters-editor/client/src/vite-env.d.ts
new file mode 100644
index 00000000..37a2d8ff
--- /dev/null
+++ b/frontend-tools/chapters-editor/client/src/vite-env.d.ts
@@ -0,0 +1,32 @@
+///
+
+declare module '*.jpg' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.jpeg' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.png' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.svg' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.gif' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.webp' {
+ const src: string;
+ export default src;
+}
+
diff --git a/frontend-tools/chapters-editor/components.json b/frontend-tools/chapters-editor/components.json
new file mode 100644
index 00000000..4d7d89f0
--- /dev/null
+++ b/frontend-tools/chapters-editor/components.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "client/src/index.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
\ No newline at end of file
diff --git a/frontend-tools/chapters-editor/generated-icon.png b/frontend-tools/chapters-editor/generated-icon.png
new file mode 100644
index 00000000..4ca2bb9e
Binary files /dev/null and b/frontend-tools/chapters-editor/generated-icon.png differ
diff --git a/frontend-tools/chapters-editor/package.json b/frontend-tools/chapters-editor/package.json
new file mode 100644
index 00000000..728ae5d0
--- /dev/null
+++ b/frontend-tools/chapters-editor/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "video-trim-js",
+ "version": "1.0.0",
+ "type": "module",
+ "license": "MIT",
+ "scripts": {
+ "dev": "vite",
+ "start": "NODE_ENV=production node dist/index.js",
+ "check": "tsc",
+ "build:django": "vite build --config vite.chapters-editor.config.ts --outDir ../../../static/chapters_editor",
+ "format": "npx prettier --write client/src/**/*.{ts,tsx,css}"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.74.4",
+ "clsx": "^2.1.1",
+ "express": "^4.21.2",
+ "express-session": "^1.18.1",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "tailwind-merge": "^2.6.0",
+ "tailwindcss-animate": "^1.0.7",
+ "tsx": "^4.19.3",
+ "zod": "^3.24.3"
+ },
+ "devDependencies": {
+ "@tailwindcss/typography": "^0.5.16",
+ "@types/express": "4.17.21",
+ "@types/express-session": "^1.18.0",
+ "@types/node": "^20.17.30",
+ "@types/passport": "^1.0.16",
+ "@types/passport-local": "^1.0.38",
+ "@types/react": "^18.3.20",
+ "@types/react-dom": "^18.3.6",
+ "@types/ws": "^8.5.13",
+ "@vitejs/plugin-react": "^4.4.1",
+ "autoprefixer": "^10.4.20",
+ "esbuild": "^0.25.0",
+ "postcss": "^8.4.47",
+ "prettier": "^3.6.0",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.8.3",
+ "vite": "^5.4.18"
+ },
+ "optionalDependencies": {
+ "bufferutil": "^4.0.8"
+ }
+}
diff --git a/frontend-tools/chapters-editor/postcss.config.js b/frontend-tools/chapters-editor/postcss.config.js
new file mode 100644
index 00000000..2e7af2b7
--- /dev/null
+++ b/frontend-tools/chapters-editor/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/frontend-tools/chapters-editor/shared/schema.ts b/frontend-tools/chapters-editor/shared/schema.ts
new file mode 100644
index 00000000..8eb7f231
--- /dev/null
+++ b/frontend-tools/chapters-editor/shared/schema.ts
@@ -0,0 +1,9 @@
+import { z } from "zod";
+
+export const insertUserSchema = z.object({
+ username: z.string(),
+ password: z.string(),
+});
+
+export type InsertUser = z.infer;
+export type User = InsertUser & { id: number };
diff --git a/frontend-tools/chapters-editor/tailwind.config.ts b/frontend-tools/chapters-editor/tailwind.config.ts
new file mode 100644
index 00000000..22ccd6cb
--- /dev/null
+++ b/frontend-tools/chapters-editor/tailwind.config.ts
@@ -0,0 +1,90 @@
+import type { Config } from "tailwindcss";
+
+export default {
+ darkMode: ["class"],
+ content: ["./client/index.html", "./client/src/**/*.{js,jsx,ts,tsx}"],
+ theme: {
+ extend: {
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ colors: {
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ chart: {
+ "1": "hsl(var(--chart-1))",
+ "2": "hsl(var(--chart-2))",
+ "3": "hsl(var(--chart-3))",
+ "4": "hsl(var(--chart-4))",
+ "5": "hsl(var(--chart-5))",
+ },
+ sidebar: {
+ DEFAULT: "hsl(var(--sidebar-background))",
+ foreground: "hsl(var(--sidebar-foreground))",
+ primary: "hsl(var(--sidebar-primary))",
+ "primary-foreground": "hsl(var(--sidebar-primary-foreground))",
+ accent: "hsl(var(--sidebar-accent))",
+ "accent-foreground": "hsl(var(--sidebar-accent-foreground))",
+ border: "hsl(var(--sidebar-border))",
+ ring: "hsl(var(--sidebar-ring))",
+ },
+ },
+ keyframes: {
+ "accordion-down": {
+ from: {
+ height: "0",
+ },
+ to: {
+ height: "var(--radix-accordion-content-height)",
+ },
+ },
+ "accordion-up": {
+ from: {
+ height: "var(--radix-accordion-content-height)",
+ },
+ to: {
+ height: "0",
+ },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ },
+ },
+ },
+ plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
+} satisfies Config;
diff --git a/frontend-tools/chapters-editor/tsconfig.json b/frontend-tools/chapters-editor/tsconfig.json
new file mode 100644
index 00000000..e8b2ba3e
--- /dev/null
+++ b/frontend-tools/chapters-editor/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["client/src/**/*"],
+ "exclude": ["node_modules", "build", "dist", "**/*.test.ts"],
+ "compilerOptions": {
+ "incremental": true,
+ "tsBuildInfoFile": "./node_modules/typescript/tsbuildinfo",
+ "noEmit": true,
+ "module": "ESNext",
+ "strict": true,
+ "lib": ["esnext", "dom", "dom.iterable"],
+ "jsx": "preserve",
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "allowImportingTsExtensions": true,
+ "moduleResolution": "bundler",
+ "baseUrl": ".",
+ "types": ["node", "vite/client"],
+ "paths": {
+ "@/*": ["./client/src/*"],
+ }
+ }
+}
diff --git a/frontend-tools/chapters-editor/vite.chapters-editor.config.ts b/frontend-tools/chapters-editor/vite.chapters-editor.config.ts
new file mode 100644
index 00000000..06387395
--- /dev/null
+++ b/frontend-tools/chapters-editor/vite.chapters-editor.config.ts
@@ -0,0 +1,56 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import path, { dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'client', 'src'),
+ },
+ },
+ root: path.resolve(__dirname, 'client'),
+ define: {
+ 'process.env': {
+ NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'production'),
+ },
+ },
+ build: {
+ minify: true,
+ sourcemap: true,
+ lib: {
+ entry: path.resolve(__dirname, 'client/src/main.tsx'),
+ name: 'ChaptersEditor',
+ formats: ['iife'],
+ fileName: () => 'chapters-editor.js',
+ },
+ rollupOptions: {
+ output: {
+ assetFileNames: (assetInfo) => {
+ if (assetInfo.name === 'style.css') return 'chapters-editor.css';
+ // Keep original names for image assets
+ if (assetInfo.name && /\.(png|jpe?g|svg|gif|webp)$/i.test(assetInfo.name)) {
+ return assetInfo.name;
+ }
+ return assetInfo.name || 'asset-[hash][extname]';
+ },
+ // Inline small assets, emit larger ones
+ inlineDynamicImports: true,
+ globals: {
+ react: 'React',
+ 'react-dom': 'ReactDOM',
+ },
+ },
+ },
+ // Output to Django's static directory
+ outDir: '../../../static/video_editor',
+ emptyOutDir: true,
+ external: ['react', 'react-dom'],
+ // Inline assets smaller than 100KB, emit larger ones
+ assetsInlineLimit: 102400,
+ },
+});
diff --git a/frontend-tools/chapters-editor/vite.config.ts b/frontend-tools/chapters-editor/vite.config.ts
new file mode 100644
index 00000000..1c15b328
--- /dev/null
+++ b/frontend-tools/chapters-editor/vite.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import path from "path";
+
+// Get current directory
+const __dirname = path.resolve();
+
+export default defineConfig({
+ plugins: [
+ react(),
+ ],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "client", "src"),
+ },
+ },
+ root: path.resolve(__dirname, "client"),
+ build: {
+ outDir: path.resolve(__dirname, "dist/public"),
+ emptyOutDir: true,
+ },
+});
diff --git a/frontend-tools/chapters-editor/yarn.lock b/frontend-tools/chapters-editor/yarn.lock
new file mode 100644
index 00000000..29b3ae83
--- /dev/null
+++ b/frontend-tools/chapters-editor/yarn.lock
@@ -0,0 +1,2330 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@alloc/quick-lru@^5.2.0":
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30"
+ integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
+
+"@ampproject/remapping@^2.2.0":
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4"
+ integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==
+ dependencies:
+ "@jridgewell/gen-mapping" "^0.3.5"
+ "@jridgewell/trace-mapping" "^0.3.24"
+
+"@babel/code-frame@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be"
+ integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==
+ dependencies:
+ "@babel/helper-validator-identifier" "^7.27.1"
+ js-tokens "^4.0.0"
+ picocolors "^1.1.1"
+
+"@babel/compat-data@^7.27.2":
+ version "7.28.0"
+ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.0.tgz#9fc6fd58c2a6a15243cd13983224968392070790"
+ integrity sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==
+
+"@babel/core@^7.28.0":
+ version "7.28.0"
+ resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.0.tgz#55dad808d5bf3445a108eefc88ea3fdf034749a4"
+ integrity sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==
+ dependencies:
+ "@ampproject/remapping" "^2.2.0"
+ "@babel/code-frame" "^7.27.1"
+ "@babel/generator" "^7.28.0"
+ "@babel/helper-compilation-targets" "^7.27.2"
+ "@babel/helper-module-transforms" "^7.27.3"
+ "@babel/helpers" "^7.27.6"
+ "@babel/parser" "^7.28.0"
+ "@babel/template" "^7.27.2"
+ "@babel/traverse" "^7.28.0"
+ "@babel/types" "^7.28.0"
+ convert-source-map "^2.0.0"
+ debug "^4.1.0"
+ gensync "^1.0.0-beta.2"
+ json5 "^2.2.3"
+ semver "^6.3.1"
+
+"@babel/generator@^7.28.0":
+ version "7.28.0"
+ resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.0.tgz#9cc2f7bd6eb054d77dc66c2664148a0c5118acd2"
+ integrity sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==
+ 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"
+
+"@babel/helper-compilation-targets@^7.27.2":
+ version "7.27.2"
+ resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d"
+ integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==
+ 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"
+
+"@babel/helper-globals@^7.28.0":
+ version "7.28.0"
+ resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674"
+ integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==
+
+"@babel/helper-module-imports@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204"
+ integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==
+ dependencies:
+ "@babel/traverse" "^7.27.1"
+ "@babel/types" "^7.27.1"
+
+"@babel/helper-module-transforms@^7.27.3":
+ version "7.27.3"
+ resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz#db0bbcfba5802f9ef7870705a7ef8788508ede02"
+ integrity sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==
+ dependencies:
+ "@babel/helper-module-imports" "^7.27.1"
+ "@babel/helper-validator-identifier" "^7.27.1"
+ "@babel/traverse" "^7.27.3"
+
+"@babel/helper-plugin-utils@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c"
+ integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==
+
+"@babel/helper-string-parser@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
+ integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
+
+"@babel/helper-validator-identifier@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8"
+ integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==
+
+"@babel/helper-validator-option@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f"
+ integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==
+
+"@babel/helpers@^7.27.6":
+ version "7.28.2"
+ resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.2.tgz#80f0918fecbfebea9af856c419763230040ee850"
+ integrity sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==
+ dependencies:
+ "@babel/template" "^7.27.2"
+ "@babel/types" "^7.28.2"
+
+"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.27.2", "@babel/parser@^7.28.0":
+ version "7.28.0"
+ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.0.tgz#979829fbab51a29e13901e5a80713dbcb840825e"
+ integrity sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==
+ dependencies:
+ "@babel/types" "^7.28.0"
+
+"@babel/plugin-transform-react-jsx-self@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz#af678d8506acf52c577cac73ff7fe6615c85fc92"
+ integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-react-jsx-source@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz#dcfe2c24094bb757bf73960374e7c55e434f19f0"
+ integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/template@^7.27.2":
+ version "7.27.2"
+ resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
+ integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==
+ dependencies:
+ "@babel/code-frame" "^7.27.1"
+ "@babel/parser" "^7.27.2"
+ "@babel/types" "^7.27.1"
+
+"@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3", "@babel/traverse@^7.28.0":
+ version "7.28.0"
+ resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.0.tgz#518aa113359b062042379e333db18380b537e34b"
+ integrity sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==
+ dependencies:
+ "@babel/code-frame" "^7.27.1"
+ "@babel/generator" "^7.28.0"
+ "@babel/helper-globals" "^7.28.0"
+ "@babel/parser" "^7.28.0"
+ "@babel/template" "^7.27.2"
+ "@babel/types" "^7.28.0"
+ debug "^4.3.1"
+
+"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.1", "@babel/types@^7.28.0", "@babel/types@^7.28.2":
+ version "7.28.2"
+ resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.2.tgz#da9db0856a9a88e0a13b019881d7513588cf712b"
+ integrity sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==
+ dependencies:
+ "@babel/helper-string-parser" "^7.27.1"
+ "@babel/helper-validator-identifier" "^7.27.1"
+
+"@esbuild/aix-ppc64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
+ integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==
+
+"@esbuild/aix-ppc64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz#a1414903bb38027382f85f03dda6065056757727"
+ integrity sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==
+
+"@esbuild/android-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052"
+ integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==
+
+"@esbuild/android-arm64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz#c859994089e9767224269884061f89dae6fb51c6"
+ integrity sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==
+
+"@esbuild/android-arm@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28"
+ integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==
+
+"@esbuild/android-arm@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.8.tgz#96a8f2ca91c6cd29ea90b1af79d83761c8ba0059"
+ integrity sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==
+
+"@esbuild/android-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e"
+ integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==
+
+"@esbuild/android-x64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.8.tgz#a3a626c4fec4a024a9fa8c7679c39996e92916f0"
+ integrity sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==
+
+"@esbuild/darwin-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a"
+ integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==
+
+"@esbuild/darwin-arm64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz#a5e1252ca2983d566af1c0ea39aded65736fc66d"
+ integrity sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==
+
+"@esbuild/darwin-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22"
+ integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==
+
+"@esbuild/darwin-x64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz#5271b0df2bb12ce8df886704bfdd1c7cc01385d2"
+ integrity sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==
+
+"@esbuild/freebsd-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e"
+ integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==
+
+"@esbuild/freebsd-arm64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz#d0a0e7fdf19733b8bb1566b81df1aa0bb7e46ada"
+ integrity sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==
+
+"@esbuild/freebsd-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261"
+ integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==
+
+"@esbuild/freebsd-x64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz#2de8b2e0899d08f1cb1ef3128e159616e7e85343"
+ integrity sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==
+
+"@esbuild/linux-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b"
+ integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==
+
+"@esbuild/linux-arm64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz#a4209efadc0c2975716458484a4e90c237c48ae9"
+ integrity sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==
+
+"@esbuild/linux-arm@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9"
+ integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==
+
+"@esbuild/linux-arm@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz#ccd9e291c24cd8d9142d819d463e2e7200d25b19"
+ integrity sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==
+
+"@esbuild/linux-ia32@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2"
+ integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==
+
+"@esbuild/linux-ia32@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz#006ad1536d0c2b28fb3a1cf0b53bcb85aaf92c4d"
+ integrity sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==
+
+"@esbuild/linux-loong64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df"
+ integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==
+
+"@esbuild/linux-loong64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz#127b3fbfb2c2e08b1397e985932f718f09a8f5c4"
+ integrity sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==
+
+"@esbuild/linux-mips64el@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe"
+ integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==
+
+"@esbuild/linux-mips64el@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz#837d1449517791e3fa7d82675a2d06d9f56cb340"
+ integrity sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==
+
+"@esbuild/linux-ppc64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4"
+ integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==
+
+"@esbuild/linux-ppc64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz#aa2e3bd93ab8df084212f1895ca4b03c42d9e0fe"
+ integrity sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==
+
+"@esbuild/linux-riscv64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc"
+ integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==
+
+"@esbuild/linux-riscv64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz#a340620e31093fef72767dd28ab04214b3442083"
+ integrity sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==
+
+"@esbuild/linux-s390x@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de"
+ integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==
+
+"@esbuild/linux-s390x@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz#ddfed266c8c13f5efb3105a0cd47f6dcd0e79e71"
+ integrity sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==
+
+"@esbuild/linux-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0"
+ integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==
+
+"@esbuild/linux-x64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz#9a4f78c75c051e8c060183ebb39a269ba936a2ac"
+ integrity sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==
+
+"@esbuild/netbsd-arm64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz#902c80e1d678047926387230bc037e63e00697d0"
+ integrity sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==
+
+"@esbuild/netbsd-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047"
+ integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==
+
+"@esbuild/netbsd-x64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz#2d9eb4692add2681ff05a14ce99de54fbed7079c"
+ integrity sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==
+
+"@esbuild/openbsd-arm64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz#89c3b998c6de739db38ab7fb71a8a76b3fa84a45"
+ integrity sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==
+
+"@esbuild/openbsd-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70"
+ integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==
+
+"@esbuild/openbsd-x64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz#2f01615cf472b0e48c077045cfd96b5c149365cc"
+ integrity sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==
+
+"@esbuild/openharmony-arm64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz#a201f720cd2c3ebf9a6033fcc3feb069a54b509a"
+ integrity sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==
+
+"@esbuild/sunos-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b"
+ integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==
+
+"@esbuild/sunos-x64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz#07046c977985a3334667f19e6ab3a01a80862afb"
+ integrity sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==
+
+"@esbuild/win32-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d"
+ integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==
+
+"@esbuild/win32-arm64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz#4a5470caf0d16127c05d4833d4934213c69392d1"
+ integrity sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==
+
+"@esbuild/win32-ia32@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b"
+ integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==
+
+"@esbuild/win32-ia32@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz#3de3e8470b7b328d99dbc3e9ec1eace207e5bbc4"
+ integrity sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==
+
+"@esbuild/win32-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c"
+ integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==
+
+"@esbuild/win32-x64@0.25.8":
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz#610d7ea539d2fcdbe39237b5cc175eb2c4451f9c"
+ integrity sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==
+
+"@isaacs/cliui@^8.0.2":
+ version "8.0.2"
+ resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
+ integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==
+ dependencies:
+ string-width "^5.1.2"
+ string-width-cjs "npm:string-width@^4.2.0"
+ strip-ansi "^7.0.1"
+ strip-ansi-cjs "npm:strip-ansi@^6.0.1"
+ wrap-ansi "^8.1.0"
+ wrap-ansi-cjs "npm:wrap-ansi@^7.0.0"
+
+"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.2", "@jridgewell/gen-mapping@^0.3.5":
+ version "0.3.12"
+ resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz#2234ce26c62889f03db3d7fea43c1932ab3e927b"
+ integrity sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==
+ dependencies:
+ "@jridgewell/sourcemap-codec" "^1.5.0"
+ "@jridgewell/trace-mapping" "^0.3.24"
+
+"@jridgewell/resolve-uri@^3.1.0":
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6"
+ integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
+
+"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0":
+ version "1.5.4"
+ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz#7358043433b2e5da569aa02cbc4c121da3af27d7"
+ integrity sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==
+
+"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28":
+ version "0.3.29"
+ resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz#a58d31eaadaf92c6695680b2e1d464a9b8fbf7fc"
+ integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==
+ dependencies:
+ "@jridgewell/resolve-uri" "^3.1.0"
+ "@jridgewell/sourcemap-codec" "^1.4.14"
+
+"@nodelib/fs.scandir@2.1.5":
+ version "2.1.5"
+ resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
+ integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==
+ dependencies:
+ "@nodelib/fs.stat" "2.0.5"
+ run-parallel "^1.1.9"
+
+"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
+ version "2.0.5"
+ resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b"
+ integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
+
+"@nodelib/fs.walk@^1.2.3":
+ version "1.2.8"
+ resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
+ integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
+ dependencies:
+ "@nodelib/fs.scandir" "2.1.5"
+ fastq "^1.6.0"
+
+"@pkgjs/parseargs@^0.11.0":
+ version "0.11.0"
+ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
+ integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
+
+"@rolldown/pluginutils@1.0.0-beta.27":
+ version "1.0.0-beta.27"
+ resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz#47d2bf4cef6d470b22f5831b420f8964e0bf755f"
+ integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==
+
+"@rollup/rollup-android-arm-eabi@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.0.tgz#b7783a0b32aa633fa2735e9e1ac2de0c80207313"
+ integrity sha512-9f3nSTFI2ivfxc7/tHBHcJ8pRnp8ROrELvsVprlQPVvcZ+j5zztYd+PTJGpyIOAdTvNwNrpCXswKSeoQcyGjMQ==
+
+"@rollup/rollup-android-arm64@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.0.tgz#d2fb87e8d352ed15a5513817978b4b89f3547556"
+ integrity sha512-tFZSEhqJ8Yrpe50TzOdeoYi72gi/jsnT7y8Qrozf3cNu28WX+s6I3XzEPUAqoaT9SAS8Xz9AzGTFlxxCH/w20w==
+
+"@rollup/rollup-darwin-arm64@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.0.tgz#a4433c387b8ebec610445b631a42c474e8bc944c"
+ integrity sha512-+DikIIs+p6yU2hF51UaWG8BnHbq90X0QIOt5zqSKSZxY+G3qqdLih214e9InJal21af2PuuxkDectetGfbVPJw==
+
+"@rollup/rollup-darwin-x64@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.0.tgz#d1f2285c9324e2a9dd284702a4e8029fae77d731"
+ integrity sha512-5a+NofhdEB/WimSlFMskbFQn1vqz1FWryYpA99trmZGO6qEmiS0IsX6w4B3d91U878Q2ZQdiaFF1gxX4P147og==
+
+"@rollup/rollup-freebsd-arm64@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.0.tgz#5ce48e85792cb149117ebc7e77907762532d1849"
+ integrity sha512-igr/RlKPS3OCy4jD3XBmAmo3UAcNZkJSubRsw1JeM8bAbwf15k/3eMZXD91bnjheijJiOJcga3kfCLKjV8IXNg==
+
+"@rollup/rollup-freebsd-x64@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.0.tgz#29c03ae6c7dcfa49ab6e1b3bb0f95711bed6cf79"
+ integrity sha512-MdigWzPSHlQzB1xZ+MdFDWTAH+kcn7UxjEBoOKuaso7z1DRlnAnrknB1mTtNOQ+GdPI8xgExAGwHeqQjntR0Cg==
+
+"@rollup/rollup-linux-arm-gnueabihf@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.0.tgz#2ffe0d9e8049386d8a625803ee30bc0ea6810281"
+ integrity sha512-dmZseE0ZwA/4yy1+BwFrDqFTjjNg24GO9xSrb1weVbt6AFkhp5pz1gVS7IMtfIvoWy8yp6q/zN0bKnefRUImvQ==
+
+"@rollup/rollup-linux-arm-musleabihf@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.0.tgz#ab01d5b33bf67fd5b6ec48deb812800e235501a1"
+ integrity sha512-fzhfn6p9Cfm3W8UrWKIa4l7Wfjs/KGdgaswMBBE3KY3Ta43jg2XsPrAtfezHpsRk0Nx+TFuS3hZk/To2N5kFPQ==
+
+"@rollup/rollup-linux-arm64-gnu@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.0.tgz#7eef79d5c8cd7ba0eadd3d23e9fec0d532bcd307"
+ integrity sha512-vVDD+iPDPmJQ5nAQ5Tifq3ywdv60FartglFI8VOCK+hcU9aoG0qlQTsDJP97O5yiTaTqlneZWoARMcVC5nyUoQ==
+
+"@rollup/rollup-linux-arm64-musl@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.0.tgz#87bbcd241fc8a79d23be1ffd333b8e5c4e4604ee"
+ integrity sha512-0d0jx08fzDHCzXqrtCMEEyxKU0SvJrWmUjUDE2/KDQ2UDJql0tfiwYvEx1oHELClKO8CNdE+AGJj+RqXscZpdQ==
+
+"@rollup/rollup-linux-loongarch64-gnu@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.0.tgz#a5207714d3dca6cc4de95204901a95bcc17614e7"
+ integrity sha512-XBYu9oW9eKJadWn8M7hkTZsD4yG+RrsTrVEgyKwb4L72cpJjRbRboTG9Lg9fec8MxJp/cfTHAocg4mnismQR8A==
+
+"@rollup/rollup-linux-ppc64-gnu@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.0.tgz#e231fd7c5a7b18dbef04a0f93f6b7618a8e73282"
+ integrity sha512-wJaRvcT17PoOK6Ggcfo3nouFlybHvARBS4jzT0PC/lg17fIJHcDS2fZz3sD+iA4nRlho2zE6OGbU0HvwATdokQ==
+
+"@rollup/rollup-linux-riscv64-gnu@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.0.tgz#e4ef1760ee218cad43f441a7bb59b6131664197d"
+ integrity sha512-GZ5bkMFteAGkcmh8x0Ok4LSa+L62Ez0tMsHPX6JtR0wl4Xc3bQcrFHDiR5DGLEDFtGrXih4Nd/UDaFqs968/wA==
+
+"@rollup/rollup-linux-riscv64-musl@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.0.tgz#dc49cb69aee50a7135b486a5db8f47146f465f5d"
+ integrity sha512-7CjPw6FflFsVOUfWOrVrREiV3IYXG4RzZ1ZQUaT3BtSK8YXN6x286o+sruPZJESIaPebYuFowmg54ZdrkVBYog==
+
+"@rollup/rollup-linux-s390x-gnu@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.0.tgz#467c43e7c39ee519c9c76d6f75fc22c0b095768e"
+ integrity sha512-nmvnl0ZiuysltcB/cKjUh40Rx4FbSyueERDsl2FLvLYr6pCgSsvGr3SocUT84svSpmloS7f1DRWqtRha74Gi1w==
+
+"@rollup/rollup-linux-x64-gnu@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.0.tgz#f094a39afaa12c26e08338a2b5d6bd63cc63ec9a"
+ integrity sha512-Cv+moII5C8RM6gZbR3cb21o6rquVDZrN2o81maROg1LFzBz2dZUwIQSxFA8GtGZ/F2KtsqQ2z3eFPBb6akvQNg==
+
+"@rollup/rollup-linux-x64-musl@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.0.tgz#173ddb452911847fc2ec8387f410378fcf88a951"
+ integrity sha512-PHcMG8DZTM9RCIjp8QIfN0VYtX0TtBPnWOTRurFhoCDoi9zptUZL2k7pCs+5rgut7JAiUsYy+huyhVKPcmxoog==
+
+"@rollup/rollup-win32-arm64-msvc@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.0.tgz#91a1b3199aedc5cd51004b21f6c465d3cf74d5d0"
+ integrity sha512-1SI/Rd47e8aQJeFWMDg16ET+fjvCcD/CzeaRmIEPmb05hx+3cCcwIF4ebUag4yTt/D1peE+Mgp0+Po3M358cAA==
+
+"@rollup/rollup-win32-ia32-msvc@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.0.tgz#da901027ad9753faa93412ed3fd9e6cacb6c8659"
+ integrity sha512-JwOCYxmumFDfDhx4kNyz6kTVK3gWzBIvVdMNzQMRDubcoGRDniOOmo6DDNP42qwZx3Bp9/6vWJ+kNzNqXoHmeA==
+
+"@rollup/rollup-win32-x64-msvc@4.46.0":
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.0.tgz#a45b0f6f45c86e355a85ba3c753bf0f59541c2c7"
+ integrity sha512-IPMIfrfkG1GaEXi+JSsQEx8x9b4b+hRZXO7KYc2pKio3zO2/VDXDs6B9Ts/nnO+25Fk1tdAVtUn60HKKPPzDig==
+
+"@tailwindcss/typography@^0.5.16":
+ version "0.5.16"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.16.tgz#a926c8f44d5c439b2915e231cad80058850047c6"
+ integrity sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==
+ dependencies:
+ lodash.castarray "^4.4.0"
+ lodash.isplainobject "^4.0.6"
+ lodash.merge "^4.6.2"
+ postcss-selector-parser "6.0.10"
+
+"@tanstack/query-core@5.83.0":
+ version "5.83.0"
+ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.83.0.tgz#ac3bf337007bb7ea97b1fd2e7c3ceb4240f36dbc"
+ integrity sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==
+
+"@tanstack/react-query@^5.74.4":
+ version "5.83.0"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.83.0.tgz#e72cacdb03d2e6a7e4f82f5b2fc228118941d499"
+ integrity sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==
+ dependencies:
+ "@tanstack/query-core" "5.83.0"
+
+"@types/babel__core@^7.20.5":
+ version "7.20.5"
+ resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017"
+ integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==
+ dependencies:
+ "@babel/parser" "^7.20.7"
+ "@babel/types" "^7.20.7"
+ "@types/babel__generator" "*"
+ "@types/babel__template" "*"
+ "@types/babel__traverse" "*"
+
+"@types/babel__generator@*":
+ version "7.27.0"
+ resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.27.0.tgz#b5819294c51179957afaec341442f9341e4108a9"
+ integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==
+ dependencies:
+ "@babel/types" "^7.0.0"
+
+"@types/babel__template@*":
+ version "7.4.4"
+ resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f"
+ integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==
+ dependencies:
+ "@babel/parser" "^7.1.0"
+ "@babel/types" "^7.0.0"
+
+"@types/babel__traverse@*":
+ version "7.20.7"
+ resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.7.tgz#968cdc2366ec3da159f61166428ee40f370e56c2"
+ integrity sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==
+ dependencies:
+ "@babel/types" "^7.20.7"
+
+"@types/body-parser@*":
+ version "1.19.6"
+ resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.6.tgz#1859bebb8fd7dac9918a45d54c1971ab8b5af474"
+ integrity sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==
+ dependencies:
+ "@types/connect" "*"
+ "@types/node" "*"
+
+"@types/connect@*":
+ version "3.4.38"
+ resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858"
+ integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==
+ dependencies:
+ "@types/node" "*"
+
+"@types/estree@1.0.8":
+ version "1.0.8"
+ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
+ integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
+
+"@types/express-serve-static-core@^4.17.33":
+ version "4.19.6"
+ resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz#e01324c2a024ff367d92c66f48553ced0ab50267"
+ integrity sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==
+ dependencies:
+ "@types/node" "*"
+ "@types/qs" "*"
+ "@types/range-parser" "*"
+ "@types/send" "*"
+
+"@types/express-serve-static-core@^5.0.0":
+ version "5.0.7"
+ resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz#2fa94879c9d46b11a5df4c74ac75befd6b283de6"
+ integrity sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==
+ dependencies:
+ "@types/node" "*"
+ "@types/qs" "*"
+ "@types/range-parser" "*"
+ "@types/send" "*"
+
+"@types/express-session@^1.18.0":
+ version "1.18.2"
+ resolved "https://registry.yarnpkg.com/@types/express-session/-/express-session-1.18.2.tgz#778dc3296da9aa97d5bf8e42358a54c52a230317"
+ integrity sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==
+ dependencies:
+ "@types/express" "*"
+
+"@types/express@*":
+ version "5.0.3"
+ resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.3.tgz#6c4bc6acddc2e2a587142e1d8be0bce20757e956"
+ integrity sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==
+ dependencies:
+ "@types/body-parser" "*"
+ "@types/express-serve-static-core" "^5.0.0"
+ "@types/serve-static" "*"
+
+"@types/express@4.17.21":
+ version "4.17.21"
+ resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d"
+ integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==
+ dependencies:
+ "@types/body-parser" "*"
+ "@types/express-serve-static-core" "^4.17.33"
+ "@types/qs" "*"
+ "@types/serve-static" "*"
+
+"@types/http-errors@*":
+ version "2.0.5"
+ resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.5.tgz#5b749ab2b16ba113423feb1a64a95dcd30398472"
+ integrity sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==
+
+"@types/mime@^1":
+ version "1.3.5"
+ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690"
+ integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==
+
+"@types/node@*":
+ version "24.1.0"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-24.1.0.tgz#0993f7dc31ab5cc402d112315b463e383d68a49c"
+ integrity sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==
+ dependencies:
+ undici-types "~7.8.0"
+
+"@types/node@^20.17.30":
+ version "20.19.9"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.9.tgz#ca9a58193fec361cc6e859d88b52261853f1f0d3"
+ integrity sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==
+ dependencies:
+ undici-types "~6.21.0"
+
+"@types/passport-local@^1.0.38":
+ version "1.0.38"
+ resolved "https://registry.yarnpkg.com/@types/passport-local/-/passport-local-1.0.38.tgz#8073758188645dde3515808999b1c218a6fe7141"
+ integrity sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==
+ dependencies:
+ "@types/express" "*"
+ "@types/passport" "*"
+ "@types/passport-strategy" "*"
+
+"@types/passport-strategy@*":
+ version "0.2.38"
+ resolved "https://registry.yarnpkg.com/@types/passport-strategy/-/passport-strategy-0.2.38.tgz#482abba0b165cd4553ec8b748f30b022bd6c04d3"
+ integrity sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==
+ dependencies:
+ "@types/express" "*"
+ "@types/passport" "*"
+
+"@types/passport@*", "@types/passport@^1.0.16":
+ version "1.0.17"
+ resolved "https://registry.yarnpkg.com/@types/passport/-/passport-1.0.17.tgz#718a8d1f7000ebcf6bbc0853da1bc8c4bc7ea5e6"
+ integrity sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==
+ dependencies:
+ "@types/express" "*"
+
+"@types/prop-types@*":
+ version "15.7.15"
+ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7"
+ integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==
+
+"@types/qs@*":
+ version "6.14.0"
+ resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.14.0.tgz#d8b60cecf62f2db0fb68e5e006077b9178b85de5"
+ integrity sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==
+
+"@types/range-parser@*":
+ version "1.2.7"
+ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
+ integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
+
+"@types/react-dom@^18.3.6":
+ version "18.3.7"
+ resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f"
+ integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==
+
+"@types/react@^18.3.20":
+ version "18.3.23"
+ resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.23.tgz#86ae6f6b95a48c418fecdaccc8069e0fbb63696a"
+ integrity sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==
+ dependencies:
+ "@types/prop-types" "*"
+ csstype "^3.0.2"
+
+"@types/send@*":
+ version "0.17.5"
+ resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.5.tgz#d991d4f2b16f2b1ef497131f00a9114290791e74"
+ integrity sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==
+ dependencies:
+ "@types/mime" "^1"
+ "@types/node" "*"
+
+"@types/serve-static@*":
+ version "1.15.8"
+ resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.8.tgz#8180c3fbe4a70e8f00b9f70b9ba7f08f35987877"
+ integrity sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==
+ dependencies:
+ "@types/http-errors" "*"
+ "@types/node" "*"
+ "@types/send" "*"
+
+"@types/ws@^8.5.13":
+ version "8.18.1"
+ resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9"
+ integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==
+ dependencies:
+ "@types/node" "*"
+
+"@vitejs/plugin-react@^4.4.1":
+ version "4.7.0"
+ resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz#647af4e7bb75ad3add578e762ad984b90f4a24b9"
+ integrity sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==
+ dependencies:
+ "@babel/core" "^7.28.0"
+ "@babel/plugin-transform-react-jsx-self" "^7.27.1"
+ "@babel/plugin-transform-react-jsx-source" "^7.27.1"
+ "@rolldown/pluginutils" "1.0.0-beta.27"
+ "@types/babel__core" "^7.20.5"
+ react-refresh "^0.17.0"
+
+accepts@~1.3.8:
+ version "1.3.8"
+ resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
+ integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
+ dependencies:
+ mime-types "~2.1.34"
+ negotiator "0.6.3"
+
+ansi-regex@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
+ integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
+
+ansi-regex@^6.0.1:
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654"
+ integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==
+
+ansi-styles@^4.0.0:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
+ integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
+ dependencies:
+ color-convert "^2.0.1"
+
+ansi-styles@^6.1.0:
+ version "6.2.1"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
+ integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
+
+any-promise@^1.0.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
+ integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==
+
+anymatch@~3.1.2:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
+ integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
+ dependencies:
+ normalize-path "^3.0.0"
+ picomatch "^2.0.4"
+
+arg@^5.0.2:
+ version "5.0.2"
+ resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
+ integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==
+
+array-flatten@1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
+ integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==
+
+autoprefixer@^10.4.20:
+ version "10.4.21"
+ resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.21.tgz#77189468e7a8ad1d9a37fbc08efc9f480cf0a95d"
+ integrity sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==
+ dependencies:
+ browserslist "^4.24.4"
+ caniuse-lite "^1.0.30001702"
+ fraction.js "^4.3.7"
+ normalize-range "^0.1.2"
+ picocolors "^1.1.1"
+ postcss-value-parser "^4.2.0"
+
+balanced-match@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+ integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+
+binary-extensions@^2.0.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522"
+ integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==
+
+body-parser@1.20.3:
+ version "1.20.3"
+ resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6"
+ integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==
+ dependencies:
+ bytes "3.1.2"
+ content-type "~1.0.5"
+ debug "2.6.9"
+ depd "2.0.0"
+ destroy "1.2.0"
+ http-errors "2.0.0"
+ iconv-lite "0.4.24"
+ on-finished "2.4.1"
+ qs "6.13.0"
+ raw-body "2.5.2"
+ type-is "~1.6.18"
+ unpipe "1.0.0"
+
+brace-expansion@^2.0.1:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
+ integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
+ dependencies:
+ balanced-match "^1.0.0"
+
+braces@^3.0.3, braces@~3.0.2:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
+ integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
+ dependencies:
+ fill-range "^7.1.1"
+
+browserslist@^4.24.0, browserslist@^4.24.4:
+ version "4.25.1"
+ resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.25.1.tgz#ba9e8e6f298a1d86f829c9b975e07948967bb111"
+ integrity sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==
+ dependencies:
+ caniuse-lite "^1.0.30001726"
+ electron-to-chromium "^1.5.173"
+ node-releases "^2.0.19"
+ update-browserslist-db "^1.1.3"
+
+bufferutil@^4.0.8:
+ version "4.0.9"
+ resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-4.0.9.tgz#6e81739ad48a95cad45a279588e13e95e24a800a"
+ integrity sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==
+ dependencies:
+ node-gyp-build "^4.3.0"
+
+bytes@3.1.2:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
+ integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
+
+call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
+ integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
+ dependencies:
+ es-errors "^1.3.0"
+ function-bind "^1.1.2"
+
+call-bound@^1.0.2:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a"
+ integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==
+ dependencies:
+ call-bind-apply-helpers "^1.0.2"
+ get-intrinsic "^1.3.0"
+
+camelcase-css@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5"
+ integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
+
+caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001726:
+ version "1.0.30001727"
+ resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz#22e9706422ad37aa50556af8c10e40e2d93a8b85"
+ integrity sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==
+
+chokidar@^3.6.0:
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
+ integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
+ dependencies:
+ anymatch "~3.1.2"
+ braces "~3.0.2"
+ glob-parent "~5.1.2"
+ is-binary-path "~2.1.0"
+ is-glob "~4.0.1"
+ normalize-path "~3.0.0"
+ readdirp "~3.6.0"
+ optionalDependencies:
+ fsevents "~2.3.2"
+
+clsx@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
+ integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
+
+color-convert@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
+ integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
+ dependencies:
+ color-name "~1.1.4"
+
+color-name@~1.1.4:
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+ integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
+commander@^4.0.0:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
+ integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
+
+content-disposition@0.5.4:
+ version "0.5.4"
+ resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
+ integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==
+ dependencies:
+ safe-buffer "5.2.1"
+
+content-type@~1.0.4, content-type@~1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
+ integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
+
+convert-source-map@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
+ integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
+
+cookie-signature@1.0.6:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
+ integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
+
+cookie-signature@1.0.7:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.7.tgz#ab5dd7ab757c54e60f37ef6550f481c426d10454"
+ integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==
+
+cookie@0.7.1:
+ version "0.7.1"
+ resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9"
+ integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==
+
+cookie@0.7.2:
+ version "0.7.2"
+ resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
+ integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
+
+cross-spawn@^7.0.6:
+ version "7.0.6"
+ resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
+ integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
+ dependencies:
+ path-key "^3.1.0"
+ shebang-command "^2.0.0"
+ which "^2.0.1"
+
+cssesc@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
+ integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
+
+csstype@^3.0.2:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
+ integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
+
+debug@2.6.9:
+ version "2.6.9"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+ integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
+ dependencies:
+ ms "2.0.0"
+
+debug@^4.1.0, debug@^4.3.1:
+ version "4.4.1"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b"
+ integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==
+ dependencies:
+ ms "^2.1.3"
+
+depd@2.0.0, depd@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
+ integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
+
+destroy@1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
+ integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
+
+didyoumean@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
+ integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
+
+dlv@^1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"
+ integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
+
+dunder-proto@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
+ integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
+ dependencies:
+ call-bind-apply-helpers "^1.0.1"
+ es-errors "^1.3.0"
+ gopd "^1.2.0"
+
+eastasianwidth@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
+ integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
+
+ee-first@1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
+ integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
+
+electron-to-chromium@^1.5.173:
+ version "1.5.191"
+ resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.191.tgz#8ae49a471447b1ceaf1d4d183a9000082f52363c"
+ integrity sha512-xcwe9ELcuxYLUFqZZxL19Z6HVKcvNkIwhbHUz7L3us6u12yR+7uY89dSl570f/IqNthx8dAw3tojG7i4Ni4tDA==
+
+emoji-regex@^8.0.0:
+ version "8.0.0"
+ resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
+ integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
+
+emoji-regex@^9.2.2:
+ version "9.2.2"
+ resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
+ integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
+
+encodeurl@~1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
+ integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
+
+encodeurl@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
+ integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
+
+es-define-property@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
+ integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
+
+es-errors@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
+ integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
+
+es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1"
+ integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==
+ dependencies:
+ es-errors "^1.3.0"
+
+esbuild@^0.21.3:
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d"
+ integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==
+ optionalDependencies:
+ "@esbuild/aix-ppc64" "0.21.5"
+ "@esbuild/android-arm" "0.21.5"
+ "@esbuild/android-arm64" "0.21.5"
+ "@esbuild/android-x64" "0.21.5"
+ "@esbuild/darwin-arm64" "0.21.5"
+ "@esbuild/darwin-x64" "0.21.5"
+ "@esbuild/freebsd-arm64" "0.21.5"
+ "@esbuild/freebsd-x64" "0.21.5"
+ "@esbuild/linux-arm" "0.21.5"
+ "@esbuild/linux-arm64" "0.21.5"
+ "@esbuild/linux-ia32" "0.21.5"
+ "@esbuild/linux-loong64" "0.21.5"
+ "@esbuild/linux-mips64el" "0.21.5"
+ "@esbuild/linux-ppc64" "0.21.5"
+ "@esbuild/linux-riscv64" "0.21.5"
+ "@esbuild/linux-s390x" "0.21.5"
+ "@esbuild/linux-x64" "0.21.5"
+ "@esbuild/netbsd-x64" "0.21.5"
+ "@esbuild/openbsd-x64" "0.21.5"
+ "@esbuild/sunos-x64" "0.21.5"
+ "@esbuild/win32-arm64" "0.21.5"
+ "@esbuild/win32-ia32" "0.21.5"
+ "@esbuild/win32-x64" "0.21.5"
+
+esbuild@^0.25.0, esbuild@~0.25.0:
+ version "0.25.8"
+ resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.8.tgz#482d42198b427c9c2f3a81b63d7663aecb1dda07"
+ integrity sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==
+ 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"
+
+escalade@^3.2.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
+ integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
+
+escape-html@~1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
+ integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
+
+etag@~1.8.1:
+ version "1.8.1"
+ resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
+ integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
+
+express-session@^1.18.1:
+ version "1.18.2"
+ resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.18.2.tgz#34db6252611b57055e877036eea09b4453dec5d8"
+ integrity sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==
+ dependencies:
+ cookie "0.7.2"
+ cookie-signature "1.0.7"
+ debug "2.6.9"
+ depd "~2.0.0"
+ on-headers "~1.1.0"
+ parseurl "~1.3.3"
+ safe-buffer "5.2.1"
+ uid-safe "~2.1.5"
+
+express@^4.21.2:
+ version "4.21.2"
+ resolved "https://registry.yarnpkg.com/express/-/express-4.21.2.tgz#cf250e48362174ead6cea4a566abef0162c1ec32"
+ integrity sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==
+ dependencies:
+ accepts "~1.3.8"
+ array-flatten "1.1.1"
+ body-parser "1.20.3"
+ content-disposition "0.5.4"
+ content-type "~1.0.4"
+ cookie "0.7.1"
+ cookie-signature "1.0.6"
+ debug "2.6.9"
+ depd "2.0.0"
+ encodeurl "~2.0.0"
+ escape-html "~1.0.3"
+ etag "~1.8.1"
+ finalhandler "1.3.1"
+ fresh "0.5.2"
+ http-errors "2.0.0"
+ merge-descriptors "1.0.3"
+ methods "~1.1.2"
+ on-finished "2.4.1"
+ parseurl "~1.3.3"
+ path-to-regexp "0.1.12"
+ proxy-addr "~2.0.7"
+ qs "6.13.0"
+ range-parser "~1.2.1"
+ safe-buffer "5.2.1"
+ send "0.19.0"
+ serve-static "1.16.2"
+ setprototypeof "1.2.0"
+ statuses "2.0.1"
+ type-is "~1.6.18"
+ utils-merge "1.0.1"
+ vary "~1.1.2"
+
+fast-glob@^3.3.2:
+ version "3.3.3"
+ resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
+ integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
+ 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"
+
+fastq@^1.6.0:
+ version "1.19.1"
+ resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5"
+ integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==
+ dependencies:
+ reusify "^1.0.4"
+
+fill-range@^7.1.1:
+ version "7.1.1"
+ resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
+ integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
+ dependencies:
+ to-regex-range "^5.0.1"
+
+finalhandler@1.3.1:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.1.tgz#0c575f1d1d324ddd1da35ad7ece3df7d19088019"
+ integrity sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==
+ dependencies:
+ debug "2.6.9"
+ encodeurl "~2.0.0"
+ escape-html "~1.0.3"
+ on-finished "2.4.1"
+ parseurl "~1.3.3"
+ statuses "2.0.1"
+ unpipe "~1.0.0"
+
+foreground-child@^3.1.0:
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
+ integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==
+ dependencies:
+ cross-spawn "^7.0.6"
+ signal-exit "^4.0.1"
+
+forwarded@0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
+ integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
+
+fraction.js@^4.3.7:
+ version "4.3.7"
+ resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
+ integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
+
+fresh@0.5.2:
+ version "0.5.2"
+ resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
+ integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
+
+fsevents@~2.3.2, fsevents@~2.3.3:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
+ integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
+
+function-bind@^1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
+ integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
+
+gensync@^1.0.0-beta.2:
+ version "1.0.0-beta.2"
+ resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
+ integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
+
+get-intrinsic@^1.2.5, get-intrinsic@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
+ integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
+ dependencies:
+ call-bind-apply-helpers "^1.0.2"
+ es-define-property "^1.0.1"
+ es-errors "^1.3.0"
+ es-object-atoms "^1.1.1"
+ function-bind "^1.1.2"
+ get-proto "^1.0.1"
+ gopd "^1.2.0"
+ has-symbols "^1.1.0"
+ hasown "^2.0.2"
+ math-intrinsics "^1.1.0"
+
+get-proto@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
+ integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
+ dependencies:
+ dunder-proto "^1.0.1"
+ es-object-atoms "^1.0.0"
+
+get-tsconfig@^4.7.5:
+ version "4.10.1"
+ resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.10.1.tgz#d34c1c01f47d65a606c37aa7a177bc3e56ab4b2e"
+ integrity sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==
+ dependencies:
+ resolve-pkg-maps "^1.0.0"
+
+glob-parent@^5.1.2, glob-parent@~5.1.2:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
+ integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
+ dependencies:
+ is-glob "^4.0.1"
+
+glob-parent@^6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
+ integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
+ dependencies:
+ is-glob "^4.0.3"
+
+glob@^10.3.10:
+ version "10.4.5"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956"
+ integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==
+ 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"
+
+gopd@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
+ integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
+
+has-symbols@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
+ integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
+
+hasown@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003"
+ integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
+ dependencies:
+ function-bind "^1.1.2"
+
+http-errors@2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
+ integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
+ dependencies:
+ depd "2.0.0"
+ inherits "2.0.4"
+ setprototypeof "1.2.0"
+ statuses "2.0.1"
+ toidentifier "1.0.1"
+
+iconv-lite@0.4.24:
+ version "0.4.24"
+ resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
+ integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
+ dependencies:
+ safer-buffer ">= 2.1.2 < 3"
+
+inherits@2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+ integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
+ipaddr.js@1.9.1:
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
+ integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
+
+is-binary-path@~2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
+ integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+ dependencies:
+ binary-extensions "^2.0.0"
+
+is-core-module@^2.16.0:
+ version "2.16.1"
+ resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4"
+ integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==
+ dependencies:
+ hasown "^2.0.2"
+
+is-extglob@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+ integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
+
+is-fullwidth-code-point@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
+ integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
+
+is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+ integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+ dependencies:
+ is-extglob "^2.1.1"
+
+is-number@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
+ integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
+
+isexe@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
+ integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
+
+jackspeak@^3.1.2:
+ version "3.4.3"
+ resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a"
+ integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==
+ dependencies:
+ "@isaacs/cliui" "^8.0.2"
+ optionalDependencies:
+ "@pkgjs/parseargs" "^0.11.0"
+
+jiti@^1.21.6:
+ version "1.21.7"
+ resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.7.tgz#9dd81043424a3d28458b193d965f0d18a2300ba9"
+ integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
+
+"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+ integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
+
+jsesc@^3.0.2:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d"
+ integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==
+
+json5@^2.2.3:
+ version "2.2.3"
+ resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
+ integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
+
+lilconfig@^3.0.0, lilconfig@^3.1.3:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4"
+ integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==
+
+lines-and-columns@^1.1.6:
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
+ integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
+
+lodash.castarray@^4.4.0:
+ version "4.4.0"
+ resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115"
+ integrity sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==
+
+lodash.isplainobject@^4.0.6:
+ version "4.0.6"
+ resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
+ integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==
+
+lodash.merge@^4.6.2:
+ version "4.6.2"
+ resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
+ integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
+
+loose-envify@^1.1.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
+ integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
+ dependencies:
+ js-tokens "^3.0.0 || ^4.0.0"
+
+lru-cache@^10.2.0:
+ version "10.4.3"
+ resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
+ integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
+
+lru-cache@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
+ integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==
+ dependencies:
+ yallist "^3.0.2"
+
+math-intrinsics@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
+ integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
+
+media-typer@0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
+ integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
+
+merge-descriptors@1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5"
+ integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==
+
+merge2@^1.3.0:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
+ integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
+
+methods@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
+ integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
+
+micromatch@^4.0.8:
+ version "4.0.8"
+ resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
+ integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
+ dependencies:
+ braces "^3.0.3"
+ picomatch "^2.3.1"
+
+mime-db@1.52.0:
+ version "1.52.0"
+ resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
+ integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
+
+mime-types@~2.1.24, mime-types@~2.1.34:
+ version "2.1.35"
+ resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
+ integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
+ dependencies:
+ mime-db "1.52.0"
+
+mime@1.6.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
+ integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
+
+minimatch@^9.0.4:
+ version "9.0.5"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5"
+ integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
+ dependencies:
+ brace-expansion "^2.0.1"
+
+"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2:
+ version "7.1.2"
+ resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707"
+ integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==
+
+ms@2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+ integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
+
+ms@2.1.3, ms@^2.1.3:
+ version "2.1.3"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
+ integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
+mz@^2.7.0:
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
+ integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
+ dependencies:
+ any-promise "^1.0.0"
+ object-assign "^4.0.1"
+ thenify-all "^1.0.0"
+
+nanoid@^3.3.11:
+ version "3.3.11"
+ resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
+ integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
+
+negotiator@0.6.3:
+ version "0.6.3"
+ resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
+ integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
+
+node-gyp-build@^4.3.0:
+ version "4.8.4"
+ resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8"
+ integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==
+
+node-releases@^2.0.19:
+ version "2.0.19"
+ resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314"
+ integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==
+
+normalize-path@^3.0.0, normalize-path@~3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
+ integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
+normalize-range@^0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
+ integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==
+
+object-assign@^4.0.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+ integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
+
+object-hash@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9"
+ integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==
+
+object-inspect@^1.13.3:
+ version "1.13.4"
+ resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213"
+ integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==
+
+on-finished@2.4.1:
+ version "2.4.1"
+ resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
+ integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
+ dependencies:
+ ee-first "1.1.1"
+
+on-headers@~1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.1.0.tgz#59da4f91c45f5f989c6e4bcedc5a3b0aed70ff65"
+ integrity sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==
+
+package-json-from-dist@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
+ integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==
+
+parseurl@~1.3.3:
+ version "1.3.3"
+ resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
+ integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+
+path-key@^3.1.0:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
+ integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
+
+path-parse@^1.0.7:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
+ integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
+
+path-scurry@^1.11.1:
+ version "1.11.1"
+ resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2"
+ integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==
+ dependencies:
+ lru-cache "^10.2.0"
+ minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
+
+path-to-regexp@0.1.12:
+ version "0.1.12"
+ resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7"
+ integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==
+
+picocolors@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
+ integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
+
+picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
+ integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+
+pify@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
+ integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
+
+pirates@^4.0.1:
+ version "4.0.7"
+ resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22"
+ integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==
+
+postcss-import@^15.1.0:
+ version "15.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70"
+ integrity sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==
+ dependencies:
+ postcss-value-parser "^4.0.0"
+ read-cache "^1.0.0"
+ resolve "^1.1.7"
+
+postcss-js@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.1.tgz#61598186f3703bab052f1c4f7d805f3991bee9d2"
+ integrity sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==
+ dependencies:
+ camelcase-css "^2.0.1"
+
+postcss-load-config@^4.0.2:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-4.0.2.tgz#7159dcf626118d33e299f485d6afe4aff7c4a3e3"
+ integrity sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==
+ dependencies:
+ lilconfig "^3.0.0"
+ yaml "^2.3.4"
+
+postcss-nested@^6.2.0:
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.2.0.tgz#4c2d22ab5f20b9cb61e2c5c5915950784d068131"
+ integrity sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==
+ dependencies:
+ postcss-selector-parser "^6.1.1"
+
+postcss-selector-parser@6.0.10:
+ version "6.0.10"
+ resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d"
+ integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==
+ dependencies:
+ cssesc "^3.0.0"
+ util-deprecate "^1.0.2"
+
+postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2:
+ version "6.1.2"
+ resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de"
+ integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==
+ dependencies:
+ cssesc "^3.0.0"
+ util-deprecate "^1.0.2"
+
+postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
+ integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
+
+postcss@^8.4.43, postcss@^8.4.47:
+ version "8.5.6"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c"
+ integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==
+ dependencies:
+ nanoid "^3.3.11"
+ picocolors "^1.1.1"
+ source-map-js "^1.2.1"
+
+prettier@^3.6.0:
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393"
+ integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==
+
+proxy-addr@~2.0.7:
+ version "2.0.7"
+ resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
+ integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
+ dependencies:
+ forwarded "0.2.0"
+ ipaddr.js "1.9.1"
+
+qs@6.13.0:
+ version "6.13.0"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906"
+ integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==
+ dependencies:
+ side-channel "^1.0.6"
+
+queue-microtask@^1.2.2:
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
+ integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+
+random-bytes@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b"
+ integrity sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==
+
+range-parser@~1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
+ integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
+
+raw-body@2.5.2:
+ version "2.5.2"
+ resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a"
+ integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==
+ dependencies:
+ bytes "3.1.2"
+ http-errors "2.0.0"
+ iconv-lite "0.4.24"
+ unpipe "1.0.0"
+
+react-dom@^18.3.1:
+ version "18.3.1"
+ resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"
+ integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==
+ dependencies:
+ loose-envify "^1.1.0"
+ scheduler "^0.23.2"
+
+react-refresh@^0.17.0:
+ version "0.17.0"
+ resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53"
+ integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==
+
+react@^18.3.1:
+ version "18.3.1"
+ resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"
+ integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==
+ dependencies:
+ loose-envify "^1.1.0"
+
+read-cache@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"
+ integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==
+ dependencies:
+ pify "^2.3.0"
+
+readdirp@~3.6.0:
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
+ integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
+ dependencies:
+ picomatch "^2.2.1"
+
+resolve-pkg-maps@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f"
+ integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==
+
+resolve@^1.1.7, resolve@^1.22.8:
+ version "1.22.10"
+ resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39"
+ integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==
+ dependencies:
+ is-core-module "^2.16.0"
+ path-parse "^1.0.7"
+ supports-preserve-symlinks-flag "^1.0.0"
+
+reusify@^1.0.4:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
+ integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
+
+rollup@^4.20.0:
+ version "4.46.0"
+ resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.46.0.tgz#f8b74becb74d26a703ae0ef737ff465a1feb9447"
+ integrity sha512-ONmkT3Ud3IfW15nl7l4qAZko5/2iZ5ALVBDh02ZSZ5IGVLJSYkRcRa3iB58VyEIyoofs9m2xdVrm+lTi97+3pw==
+ dependencies:
+ "@types/estree" "1.0.8"
+ optionalDependencies:
+ "@rollup/rollup-android-arm-eabi" "4.46.0"
+ "@rollup/rollup-android-arm64" "4.46.0"
+ "@rollup/rollup-darwin-arm64" "4.46.0"
+ "@rollup/rollup-darwin-x64" "4.46.0"
+ "@rollup/rollup-freebsd-arm64" "4.46.0"
+ "@rollup/rollup-freebsd-x64" "4.46.0"
+ "@rollup/rollup-linux-arm-gnueabihf" "4.46.0"
+ "@rollup/rollup-linux-arm-musleabihf" "4.46.0"
+ "@rollup/rollup-linux-arm64-gnu" "4.46.0"
+ "@rollup/rollup-linux-arm64-musl" "4.46.0"
+ "@rollup/rollup-linux-loongarch64-gnu" "4.46.0"
+ "@rollup/rollup-linux-ppc64-gnu" "4.46.0"
+ "@rollup/rollup-linux-riscv64-gnu" "4.46.0"
+ "@rollup/rollup-linux-riscv64-musl" "4.46.0"
+ "@rollup/rollup-linux-s390x-gnu" "4.46.0"
+ "@rollup/rollup-linux-x64-gnu" "4.46.0"
+ "@rollup/rollup-linux-x64-musl" "4.46.0"
+ "@rollup/rollup-win32-arm64-msvc" "4.46.0"
+ "@rollup/rollup-win32-ia32-msvc" "4.46.0"
+ "@rollup/rollup-win32-x64-msvc" "4.46.0"
+ fsevents "~2.3.2"
+
+run-parallel@^1.1.9:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
+ integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
+ dependencies:
+ queue-microtask "^1.2.2"
+
+safe-buffer@5.2.1:
+ version "5.2.1"
+ resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
+ integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
+
+"safer-buffer@>= 2.1.2 < 3":
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+ integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
+
+scheduler@^0.23.2:
+ version "0.23.2"
+ resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
+ integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
+ dependencies:
+ loose-envify "^1.1.0"
+
+semver@^6.3.1:
+ version "6.3.1"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
+ integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
+
+send@0.19.0:
+ version "0.19.0"
+ resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8"
+ integrity sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==
+ dependencies:
+ debug "2.6.9"
+ depd "2.0.0"
+ destroy "1.2.0"
+ encodeurl "~1.0.2"
+ escape-html "~1.0.3"
+ etag "~1.8.1"
+ fresh "0.5.2"
+ http-errors "2.0.0"
+ mime "1.6.0"
+ ms "2.1.3"
+ on-finished "2.4.1"
+ range-parser "~1.2.1"
+ statuses "2.0.1"
+
+serve-static@1.16.2:
+ version "1.16.2"
+ resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.2.tgz#b6a5343da47f6bdd2673848bf45754941e803296"
+ integrity sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==
+ dependencies:
+ encodeurl "~2.0.0"
+ escape-html "~1.0.3"
+ parseurl "~1.3.3"
+ send "0.19.0"
+
+setprototypeof@1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
+ integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
+
+shebang-command@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
+ integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
+ dependencies:
+ shebang-regex "^3.0.0"
+
+shebang-regex@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
+ integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
+
+side-channel-list@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
+ integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==
+ dependencies:
+ es-errors "^1.3.0"
+ object-inspect "^1.13.3"
+
+side-channel-map@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42"
+ integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==
+ dependencies:
+ call-bound "^1.0.2"
+ es-errors "^1.3.0"
+ get-intrinsic "^1.2.5"
+ object-inspect "^1.13.3"
+
+side-channel-weakmap@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea"
+ integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==
+ dependencies:
+ call-bound "^1.0.2"
+ es-errors "^1.3.0"
+ get-intrinsic "^1.2.5"
+ object-inspect "^1.13.3"
+ side-channel-map "^1.0.1"
+
+side-channel@^1.0.6:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9"
+ integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==
+ dependencies:
+ es-errors "^1.3.0"
+ object-inspect "^1.13.3"
+ side-channel-list "^1.0.0"
+ side-channel-map "^1.0.1"
+ side-channel-weakmap "^1.0.2"
+
+signal-exit@^4.0.1:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
+ integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
+
+source-map-js@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
+ integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
+
+statuses@2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
+ integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
+
+"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
+ version "4.2.3"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+ integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+ dependencies:
+ emoji-regex "^8.0.0"
+ is-fullwidth-code-point "^3.0.0"
+ strip-ansi "^6.0.1"
+
+string-width@^5.0.1, string-width@^5.1.2:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
+ integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==
+ dependencies:
+ eastasianwidth "^0.2.0"
+ emoji-regex "^9.2.2"
+ strip-ansi "^7.0.1"
+
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+ integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+ dependencies:
+ ansi-regex "^5.0.1"
+
+strip-ansi@^7.0.1:
+ version "7.1.0"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
+ integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==
+ dependencies:
+ ansi-regex "^6.0.1"
+
+sucrase@^3.35.0:
+ version "3.35.0"
+ resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263"
+ integrity sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==
+ dependencies:
+ "@jridgewell/gen-mapping" "^0.3.2"
+ commander "^4.0.0"
+ glob "^10.3.10"
+ lines-and-columns "^1.1.6"
+ mz "^2.7.0"
+ pirates "^4.0.1"
+ ts-interface-checker "^0.1.9"
+
+supports-preserve-symlinks-flag@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
+ integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+
+tailwind-merge@^2.6.0:
+ version "2.6.0"
+ resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5"
+ integrity sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==
+
+tailwindcss-animate@^1.0.7:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4"
+ integrity sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==
+
+tailwindcss@^3.4.17:
+ version "3.4.17"
+ resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.17.tgz#ae8406c0f96696a631c790768ff319d46d5e5a63"
+ integrity sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==
+ dependencies:
+ "@alloc/quick-lru" "^5.2.0"
+ arg "^5.0.2"
+ chokidar "^3.6.0"
+ didyoumean "^1.2.2"
+ dlv "^1.1.3"
+ fast-glob "^3.3.2"
+ glob-parent "^6.0.2"
+ is-glob "^4.0.3"
+ jiti "^1.21.6"
+ lilconfig "^3.1.3"
+ micromatch "^4.0.8"
+ normalize-path "^3.0.0"
+ object-hash "^3.0.0"
+ picocolors "^1.1.1"
+ postcss "^8.4.47"
+ postcss-import "^15.1.0"
+ postcss-js "^4.0.1"
+ postcss-load-config "^4.0.2"
+ postcss-nested "^6.2.0"
+ postcss-selector-parser "^6.1.2"
+ resolve "^1.22.8"
+ sucrase "^3.35.0"
+
+thenify-all@^1.0.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
+ integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==
+ dependencies:
+ thenify ">= 3.1.0 < 4"
+
+"thenify@>= 3.1.0 < 4":
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f"
+ integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
+ dependencies:
+ any-promise "^1.0.0"
+
+to-regex-range@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
+ integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
+ dependencies:
+ is-number "^7.0.0"
+
+toidentifier@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
+ integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+
+ts-interface-checker@^0.1.9:
+ version "0.1.13"
+ resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
+ integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
+
+tsx@^4.19.3:
+ version "4.20.3"
+ resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.20.3.tgz#f913e4911d59ad177c1bcee19d1035ef8dd6e2fb"
+ integrity sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==
+ dependencies:
+ esbuild "~0.25.0"
+ get-tsconfig "^4.7.5"
+ optionalDependencies:
+ fsevents "~2.3.3"
+
+type-is@~1.6.18:
+ version "1.6.18"
+ resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
+ integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
+ dependencies:
+ media-typer "0.3.0"
+ mime-types "~2.1.24"
+
+typescript@^5.8.3:
+ version "5.8.3"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e"
+ integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
+
+uid-safe@~2.1.5:
+ version "2.1.5"
+ resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a"
+ integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==
+ dependencies:
+ random-bytes "~1.0.0"
+
+undici-types@~6.21.0:
+ version "6.21.0"
+ resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
+ integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
+
+undici-types@~7.8.0:
+ version "7.8.0"
+ resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.8.0.tgz#de00b85b710c54122e44fbfd911f8d70174cd294"
+ integrity sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==
+
+unpipe@1.0.0, unpipe@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
+ integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
+
+update-browserslist-db@^1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420"
+ integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==
+ dependencies:
+ escalade "^3.2.0"
+ picocolors "^1.1.1"
+
+util-deprecate@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+ integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
+
+utils-merge@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
+ integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
+
+vary@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+ integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
+
+vite@^5.4.18:
+ version "5.4.19"
+ resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.19.tgz#20efd060410044b3ed555049418a5e7d1998f959"
+ integrity sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==
+ dependencies:
+ esbuild "^0.21.3"
+ postcss "^8.4.43"
+ rollup "^4.20.0"
+ optionalDependencies:
+ fsevents "~2.3.3"
+
+which@^2.0.1:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
+ integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
+ dependencies:
+ isexe "^2.0.0"
+
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+ integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+ dependencies:
+ ansi-styles "^4.0.0"
+ string-width "^4.1.0"
+ strip-ansi "^6.0.0"
+
+wrap-ansi@^8.1.0:
+ version "8.1.0"
+ resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
+ integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==
+ dependencies:
+ ansi-styles "^6.1.0"
+ string-width "^5.0.1"
+ strip-ansi "^7.0.1"
+
+yallist@^3.0.2:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
+ integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
+
+yaml@^2.3.4:
+ version "2.8.0"
+ resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.0.tgz#15f8c9866211bdc2d3781a0890e44d4fa1a5fff6"
+ integrity sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==
+
+zod@^3.24.3:
+ version "3.25.76"
+ resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
+ integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
diff --git a/frontend-tools/static/video_editor/video-editor.css b/frontend-tools/static/video_editor/video-editor.css
deleted file mode 100644
index 31343841..00000000
--- a/frontend-tools/static/video_editor/video-editor.css
+++ /dev/null
@@ -1 +0,0 @@
-#video-editor-trim-root .video-player-container{position:relative;background-color:#000;border-radius:.5rem;overflow:hidden;margin-bottom:1rem;aspect-ratio:16/9}#video-editor-trim-root .video-player-container video{width:100%;height:100%;cursor:pointer}#video-editor-trim-root .play-pause-indicator{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:60px;height:60px;background-color:#0009;border-radius:50%;opacity:0;transition:opacity .3s}#video-editor-trim-root .play-pause-indicator:before{content:"";position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}#video-editor-trim-root .play-pause-indicator.play-icon:before{width:0;height:0;border-top:15px solid transparent;border-bottom:15px solid transparent;border-left:25px solid white;margin-left:3px}#video-editor-trim-root .play-pause-indicator.pause-icon:before{width:20px;height:25px;border-left:6px solid white;border-right:6px solid white}#video-editor-trim-root .video-player-container:hover .play-pause-indicator{opacity:1}#video-editor-trim-root .video-controls{position:absolute;bottom:0;left:0;right:0;padding:.75rem;background:linear-gradient(transparent,#000000b3);opacity:0;transition:opacity .3s}#video-editor-trim-root .video-player-container:hover .video-controls{opacity:1}#video-editor-trim-root .video-current-time,#video-editor-trim-root .video-duration{color:#fff;font-size:.875rem}#video-editor-trim-root .video-time-display{display:flex;justify-content:space-between;margin-bottom:.5rem;color:#fff;font-size:.875rem}#video-editor-trim-root .video-progress{width:100%;height:4px;background-color:#ffffff4d;border-radius:2px;position:relative;cursor:pointer;margin-bottom:.75rem}#video-editor-trim-root .video-progress:hover{height:6px}#video-editor-trim-root .video-progress:hover .video-scrubber{transform:translate(-50%,-50%) scale(1.2)}#video-editor-trim-root .video-progress-fill{height:100%;background-color:#ef4444;border-radius:2px;position:absolute;top:0;left:0}#video-editor-trim-root .video-scrubber{width:12px;height:12px;background-color:#ef4444;border-radius:50%;position:absolute;top:50%;left:0;transform:translate(-50%,-50%);transition:transform .2s;border:1px solid rgba(255,255,255,.7)}#video-editor-trim-root .video-controls-buttons{display:flex;align-items:center;justify-content:flex-end;gap:.75rem}#video-editor-trim-root .mute-button,#video-editor-trim-root .fullscreen-button{min-width:auto;color:#fff;background:none;border:none;cursor:pointer;padding:.25rem;transition:transform .2s}#video-editor-trim-root .mute-button:hover,#video-editor-trim-root .fullscreen-button:hover{transform:scale(1.1)}#video-editor-trim-root .mute-button svg,#video-editor-trim-root .fullscreen-button svg{width:1.25rem;height:1.25rem}.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal-container{background-color:#fff;border-radius:8px;box-shadow:0 4px 12px #00000026;width:90%;max-width:500px;max-height:90vh;overflow-y:auto;animation:modal-fade-in .3s ease-out}@keyframes modal-fade-in{0%{opacity:0;transform:translateY(-20px)}to{opacity:1;transform:translateY(0)}}.modal-header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid #eee}.modal-title{margin:0;font-size:1.25rem;font-weight:600;color:#333}.modal-close-button{background:none;border:none;cursor:pointer;color:#666;padding:4px;display:flex;align-items:center;justify-content:center;transition:color .2s}.modal-close-button:hover{color:#000}.modal-content{padding:20px;color:#333;font-size:1rem;line-height:1.5}.modal-actions{display:flex;justify-content:flex-end;padding:16px 20px;border-top:1px solid #eee;gap:12px}.modal-button{padding:8px 16px;border-radius:4px;font-weight:500;cursor:pointer;transition:all .2s;border:none}.modal-button-primary{background-color:#06c;color:#fff}.modal-button-primary:hover{background-color:#05a}.modal-button-secondary{background-color:#f0f0f0;color:#333}.modal-button-secondary:hover{background-color:#e0e0e0}.modal-button-danger{background-color:#dc3545;color:#fff}.modal-button-danger:hover{background-color:#bd2130}.modal-message{margin-bottom:16px;font-size:1rem}.modal-spinner{display:flex;align-items:center;justify-content:center;margin:20px 0}.spinner{border:4px solid rgba(0,0,0,.1);border-radius:50%;border-top:4px solid #0066cc;width:30px;height:30px;animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.modal-success-icon{display:flex;justify-content:center;margin-bottom:16px;color:#28a745;font-size:2rem}.modal-choices{display:flex;flex-direction:column;gap:10px;margin-top:20px}.modal-choice-button{padding:12px 16px;border:1px solid #ddd;border-radius:4px;background-color:#f8f8f8;text-align:center;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;font-weight:500;text-decoration:none;color:#333}.modal-choice-button:hover{background-color:#eee;border-color:#ccc}.modal-choice-button svg{margin-right:8px}@media (max-width: 480px){.modal-container{width:95%}.modal-actions{flex-direction:column}.modal-button{width:100%}}#video-editor-trim-root .timeline-container-card{background-color:#fff;border-radius:.5rem;padding:1rem;box-shadow:0 1px 2px #0000000d}#video-editor-trim-root .timeline-header{margin-bottom:.75rem;display:flex;justify-content:space-between;align-items:center}#video-editor-trim-root .timeline-title{font-size:.875rem;font-weight:500;color:var(--foreground, #333)}#video-editor-trim-root .timeline-title-text{font-weight:700}#video-editor-trim-root .current-time{font-size:.875rem;color:var(--foreground, #333)}#video-editor-trim-root .time-code{font-family:monospace;background-color:#f3f4f6;padding:0 .5rem;border-radius:.25rem}#video-editor-trim-root .duration-time{font-size:.875rem;color:var(--foreground, #333)}#video-editor-trim-root .timeline-scroll-container{position:relative}#video-editor-trim-root .timeline-container{position:relative;min-width:100%;background-color:#fafbfc;height:70px;border-radius:.25rem}#video-editor-trim-root .timeline-marker{position:absolute;top:0;bottom:0;width:1px;background-color:red;z-index:30;pointer-events:none}#video-editor-trim-root .timeline-marker:after{content:"";position:absolute;top:0;left:-3px;width:7px;height:7px;background-color:red;border-radius:50%}#video-editor-trim-root .trim-line-marker{position:absolute;top:0;bottom:0;width:1px;background-color:#00000080;z-index:20}#video-editor-trim-root .trim-handle{position:absolute;width:10px;height:20px;background-color:#000;cursor:ew-resize}#video-editor-trim-root .trim-handle.left{right:0;top:10px;border-radius:3px 0 0 3px}#video-editor-trim-root .trim-handle.right{left:0;top:10px;border-radius:0 3px 3px 0}#video-editor-trim-root .timeline-thumbnail{display:inline-block;height:70px;border-right:1px solid rgba(0,0,0,.03)}#video-editor-trim-root .split-point{position:absolute;top:0;bottom:0;width:1px;background-color:#ff000080;z-index:15}#video-editor-trim-root .clip-segment{position:absolute;height:70px;border-radius:4px;z-index:10;border:2px solid rgba(0,0,0,.15);cursor:pointer}#video-editor-trim-root .clip-segment:hover{box-shadow:0 0 0 2px #0000004d;border-color:#0006;background-color:#f0f0f0cc!important}#video-editor-trim-root .clip-segment.selected{box-shadow:0 0 0 2px #3b82f6b3;border-color:#3b82f6e6}#video-editor-trim-root .clip-segment.selected:hover{background-color:#f0f8ffd9!important}#video-editor-trim-root .clip-segment-info{position:absolute;bottom:0;left:0;right:0;padding:.4rem;background-color:#0006;color:#fff;opacity:1;transition:background-color .2s;line-height:1.3}#video-editor-trim-root .clip-segment:hover .clip-segment-info{background-color:#00000080}#video-editor-trim-root .clip-segment.selected .clip-segment-info{background-color:#3b82f680}#video-editor-trim-root .clip-segment.selected:hover .clip-segment-info{background-color:#3b82f666}#video-editor-trim-root .clip-segment-name{font-weight:700;font-size:12px}#video-editor-trim-root .clip-segment-time,#video-editor-trim-root .clip-segment-duration{font-size:10px}#video-editor-trim-root .clip-segment-handle{position:absolute;top:0;bottom:0;width:6px;background-color:#0003;cursor:ew-resize}#video-editor-trim-root .clip-segment-handle:hover{background-color:#0006}#video-editor-trim-root .clip-segment-handle.left{left:0;border-radius:2px 0 0 2px}#video-editor-trim-root .clip-segment-handle.right{right:0;border-radius:0 2px 2px 0}#video-editor-trim-root .segment-tooltip,#video-editor-trim-root .empty-space-tooltip{position:fixed;background-color:#fff;border-radius:4px;box-shadow:0 2px 8px #0000004d;padding:.5rem;z-index:100;min-width:150px;text-align:center}#video-editor-trim-root .segment-tooltip:after,#video-editor-trim-root .empty-space-tooltip:after{content:"";position:absolute;bottom:-5px;left:50%;transform:translate(-50%);width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:5px solid white}#video-editor-trim-root .tooltip-time{font-weight:600;font-size:.875rem;margin-bottom:.5rem;color:#333}#video-editor-trim-root .tooltip-actions{display:flex;justify-content:center;gap:.5rem}#video-editor-trim-root .tooltip-action-btn{background-color:#f3f4f6;border:none;border-radius:.25rem;padding:.375rem;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#4b5563}#video-editor-trim-root .tooltip-action-btn:hover{background-color:#e5e7eb;color:#111827}#video-editor-trim-root .tooltip-action-btn.delete{color:#ef4444}#video-editor-trim-root .tooltip-action-btn.delete:hover{background-color:#fee2e2}#video-editor-trim-root .tooltip-action-btn.new-segment{padding:.375rem .5rem}#video-editor-trim-root .tooltip-action-btn.new-segment .tooltip-btn-text{margin-left:.25rem;font-size:.75rem}#video-editor-trim-root .tooltip-action-btn svg{width:1rem;height:1rem}#video-editor-trim-root .timeline-controls{display:flex;align-items:center;justify-content:space-between;margin-top:.75rem}#video-editor-trim-root .time-navigation{display:flex;align-items:center;gap:.5rem}#video-editor-trim-root .time-nav-label{font-size:.875rem;font-weight:500}#video-editor-trim-root .time-input{border:1px solid #d1d5db;border-radius:.25rem;padding:.25rem .5rem;width:8rem;font-size:.875rem}#video-editor-trim-root .time-button-group{display:flex}#video-editor-trim-root .time-button{background-color:#e5e7eb;color:#000;padding:.25rem .5rem;font-size:.875rem;border:none;cursor:pointer;margin-right:.5rem}#video-editor-trim-root .time-button:hover{background-color:#d1d5db}#video-editor-trim-root .time-button:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}#video-editor-trim-root .time-button:last-child{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}#video-editor-trim-root .controls-right{display:flex;align-items:center;gap:.5rem}#video-editor-trim-root .zoom-dropdown-container{position:relative;z-index:100;display:none}#video-editor-trim-root .zoom-button{background-color:#374151;color:#fff;border:none;border-radius:.25rem;padding:.25rem .75rem;font-size:.875rem;display:flex;align-items:center;cursor:pointer}#video-editor-trim-root .zoom-button:hover{background-color:#1f2937}#video-editor-trim-root .zoom-button svg{margin-left:.25rem}#video-editor-trim-root .zoom-dropdown{position:absolute;top:100%;left:0;margin-top:.25rem;width:9rem;background-color:#374151;color:#fff;border-radius:.25rem;box-shadow:0 4px 6px -1px #0000001a;z-index:50;max-height:300px;overflow-y:auto}#video-editor-trim-root .zoom-option{padding:.25rem .75rem;cursor:pointer}#video-editor-trim-root .zoom-option:hover{background-color:#4b5563}#video-editor-trim-root .zoom-option.selected{background-color:#6b7280;display:flex;align-items:center}#video-editor-trim-root .zoom-option svg{margin-right:.25rem}#video-editor-trim-root .save-button{color:#fff;background:#06c;border-radius:.25rem;font-size:.875rem;padding:.25rem .5rem;cursor:pointer;border:none}#video-editor-trim-root .save-button:hover{background-color:#093b6de6}#video-editor-trim-root .save-copy-button{background:#06c;color:#fff;border:none;border-radius:.25rem;font-size:.875rem;padding:.25rem .5rem;cursor:pointer}#video-editor-trim-root .save-copy-button:hover{background-color:#093b6de6}#video-editor-trim-root [data-tooltip]{position:relative}#video-editor-trim-root [data-tooltip]:before{content:attr(data-tooltip);position:absolute;bottom:100%;left:50%;transform:translate(-50%);margin-bottom:5px;background-color:#000c;color:#fff;text-align:center;padding:5px 10px;border-radius:3px;font-size:12px;white-space:nowrap;opacity:0;visibility:hidden;transition:opacity .2s,visibility .2s;z-index:1000;pointer-events:none}#video-editor-trim-root [data-tooltip]:after{content:"";position:absolute;bottom:100%;left:50%;transform:translate(-50%);border-width:5px;border-style:solid;border-color:rgba(0,0,0,.8) transparent transparent transparent;opacity:0;visibility:hidden;transition:opacity .2s,visibility .2s;pointer-events:none}#video-editor-trim-root [data-tooltip]:hover:before,#video-editor-trim-root [data-tooltip]:hover:after{opacity:1;visibility:visible}#video-editor-trim-root .editing-tools-container{background-color:#fff;border-radius:.5rem;padding:1rem;margin-bottom:1rem;box-shadow:0 1px 2px #0000000d}#video-editor-trim-root .flex-container{display:flex;justify-content:space-between;align-items:center;position:relative;flex-wrap:wrap;gap:15px}#video-editor-trim-root .button-group{display:flex}#video-editor-trim-root .button-group.preview-group{gap:1rem}#video-editor-trim-root .button-group.center-play{position:absolute;left:50%;transform:translate(-50%);z-index:10}#video-editor-trim-root .button-group.secondary{gap:.75rem;align-items:center}#video-editor-trim-root .button-group button{display:flex;align-items:center;color:#333;background:none;border:none;cursor:pointer;transition:color .2s;min-width:auto}#video-editor-trim-root .button-group button:hover:not(:disabled){color:#06c}#video-editor-trim-root .button-group button:disabled{opacity:.5;cursor:not-allowed}#video-editor-trim-root .button-group button svg{height:1.25rem;width:1.25rem;margin-right:.25rem}#video-editor-trim-root .divider{border-right:1px solid #d1d5db;height:1.5rem;margin:0 .5rem}#video-editor-trim-root .play-button,#video-editor-trim-root .preview-button{font-weight:600;display:flex;align-items:center;position:relative;overflow:hidden;transform-origin:center center;transition:transform .2s ease,color .2s ease;min-width:80px;justify-content:center}#video-editor-trim-root .play-button:hover:not(:disabled),#video-editor-trim-root .preview-button:hover:not(:disabled){color:#06c;transform:scale(1.05);font-size:inherit;width:auto}#video-editor-trim-root .play-button svg,#video-editor-trim-root .preview-button svg{height:1.5rem;width:1.5rem;flex-shrink:0}@media (max-width: 768px){#video-editor-trim-root .flex-container{justify-content:space-between;padding-top:10px}#video-editor-trim-root .button-group.secondary{justify-content:flex-end;width:auto}#video-editor-trim-root .button-group.preview-group{justify-content:flex-start}#video-editor-trim-root .button-group.center-play{position:absolute;left:50%;transform:translate(-50%);top:0}}@media (max-width: 640px){#video-editor-trim-root .flex-container{padding-top:0}#video-editor-trim-root .button-group.center-play{position:static;transform:none;margin-left:20px}}@media (max-width: 480px){#video-editor-trim-root .flex-container{flex-direction:column;align-items:center}#video-editor-trim-root .button-group.secondary{width:100%;justify-content:space-between;margin-left:0}#video-editor-trim-root .button-group.preview-group,#video-editor-trim-root .button-group.center-play{width:auto;justify-content:center;margin-left:0}#video-editor-trim-root .button-group.center-play{margin-top:10px;margin-bottom:10px}#video-editor-trim-root .divider{display:none}}#video-editor-trim-root .clip-segments-container{margin-top:1rem;background-color:#fff;border-radius:.5rem;padding:1rem;box-shadow:0 1px 2px #0000000d}#video-editor-trim-root .clip-segments-title{font-size:.875rem;font-weight:500;color:var(--foreground, #333);margin-bottom:.75rem}#video-editor-trim-root .segment-item{display:flex;align-items:center;justify-content:space-between;padding:.5rem;border:1px solid #e5e7eb;border-radius:.25rem;margin-bottom:.5rem;transition:box-shadow .2s ease}#video-editor-trim-root .segment-item:hover{box-shadow:0 4px 6px -1px #0000001a}#video-editor-trim-root .segment-content{display:flex;align-items:center}#video-editor-trim-root .segment-thumbnail{width:4rem;height:2.25rem;background-size:cover;background-position:center;border-radius:.25rem;margin-right:.75rem;box-shadow:0 0 0 1px #ffffff4d}#video-editor-trim-root .segment-info{display:flex;flex-direction:column}#video-editor-trim-root .segment-title{font-weight:500;font-size:.875rem;color:#000}#video-editor-trim-root .segment-time{font-size:.75rem;color:#000}#video-editor-trim-root .segment-duration{font-size:.75rem;margin-top:.25rem;display:inline-block;background-color:#f3f4f6;padding:0 .5rem;border-radius:.25rem;color:#000}#video-editor-trim-root .segment-actions{display:flex;align-items:center;gap:.5rem}#video-editor-trim-root .delete-button{padding:.375rem;color:#4b5563;background-color:#e5e7eb;border-radius:9999px;border:none;cursor:pointer;transition:background-color .2s,color .2s;min-width:auto}#video-editor-trim-root .delete-button:hover{color:#000;background-color:#d1d5db}#video-editor-trim-root .delete-button svg{height:1rem;width:1rem}#video-editor-trim-root .empty-message{padding:1rem;text-align:center;color:#333333b3}#video-editor-trim-root .segment-color-1{background-color:#3b82f626}#video-editor-trim-root .segment-color-2{background-color:#10b98126}#video-editor-trim-root .segment-color-3{background-color:#f59e0b26}#video-editor-trim-root .segment-color-4{background-color:#ef444426}#video-editor-trim-root .segment-color-5{background-color:#8b5cf626}#video-editor-trim-root .segment-color-6{background-color:#ec489926}#video-editor-trim-root .segment-color-7{background-color:#06b6d426}#video-editor-trim-root .segment-color-8{background-color:#facc1526}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}*{border-color:hsl(var(--border))}.container{width:100%}@media (min-width: 640px){.container{max-width:640px}}@media (min-width: 768px){.container{max-width:768px}}@media (min-width: 1024px){.container{max-width:1024px}}@media (min-width: 1280px){.container{max-width:1280px}}@media (min-width: 1536px){.container{max-width:1536px}}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.mx-auto{margin-left:auto;margin-right:auto}.hidden{display:none}.min-h-screen{min-height:100vh}.max-w-6xl{max-width:72rem}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.resize{resize:both}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.border{border-width:1px}.bg-background{background-color:hsl(var(--background))}.px-4{padding-left:1rem;padding-right:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.text-center{text-align:center}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}@keyframes enter{0%{opacity:var(--tw-enter-opacity, 1);transform:translate3d(var(--tw-enter-translate-x, 0),var(--tw-enter-translate-y, 0),0) scale3d(var(--tw-enter-scale, 1),var(--tw-enter-scale, 1),var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity, 1);transform:translate3d(var(--tw-exit-translate-x, 0),var(--tw-exit-translate-y, 0),0) scale3d(var(--tw-exit-scale, 1),var(--tw-exit-scale, 1),var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))}}:root{--foreground: 20 14.3% 4.1%;--muted: 60 4.8% 95.9%;--muted-foreground: 25 5.3% 44.7%;--popover: 0 0% 100%;--popover-foreground: 20 14.3% 4.1%;--card: 0 0% 100%;--card-foreground: 20 14.3% 4.1%;--border: 20 5.9% 90%;--input: 20 5.9% 90%;--primary: 207 90% 54%;--primary-foreground: 211 100% 99%;--secondary: 30 84% 54%;--secondary-foreground: 60 9.1% 97.8%;--accent: 60 4.8% 95.9%;--accent-foreground: 24 9.8% 10%;--destructive: 0 84.2% 60.2%;--destructive-foreground: 60 9.1% 97.8%;--ring: 20 14.3% 4.1%;--radius: .5rem}.video-player{position:relative;width:100%;background-color:#000;overflow:hidden;border-radius:.5rem}.video-controls{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(to top,rgba(0,0,0,.8),transparent);padding:1rem;display:flex;flex-direction:column}.video-current-time{color:#fff;font-weight:500}.video-progress{position:relative;height:4px;background-color:#ffffff4d;border-radius:2px;margin-bottom:1rem}.video-progress-fill{position:absolute;left:0;top:0;height:100%;background-color:hsl(var(--primary));border-radius:2px}.video-scrubber{position:absolute;width:12px;height:12px;margin-left:-6px;background-color:#fff;border-radius:50%;top:-4px}.video-player-container{position:relative;overflow:hidden}.play-pause-indicator{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:70px;height:70px;border-radius:50%;background-color:#00000080;z-index:20;opacity:0;transition:opacity .2s ease;pointer-events:none;background-position:center;background-repeat:no-repeat}.play-icon{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E")}.pause-icon{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M6 19h4V5H6v14zm8-14v14h4V5h-4z'/%3E%3C/svg%3E")}.video-player-container:hover .play-pause-indicator{opacity:1}.timeline-scroll-container{height:6rem;border-radius:.375rem;overflow-x:auto;overflow-y:hidden;margin-bottom:.75rem;background-color:#eee;position:relative}.timeline-container{position:relative;background-color:#eee;height:6rem;width:100%;cursor:pointer;transition:width .3s ease}.timeline-marker{position:absolute;top:-10px;height:calc(100% + 10px);width:2px;background-color:red;z-index:100;pointer-events:none;box-shadow:0 0 4px #ff000080}.trim-line-marker{position:absolute;top:0;bottom:0;width:2px;background-color:#007bffe6;z-index:10}.trim-handle{width:8px;background-color:#6c757de6;position:absolute;top:0;bottom:0;cursor:ew-resize;z-index:15}.trim-handle.left{left:-4px}.trim-handle.right{right:-4px}.timeline-thumbnail{height:100%;border-right:1px solid rgba(0,0,0,.1);position:relative;display:inline-block;background-size:cover;background-position:center}.split-point{position:absolute;width:2px;background-color:#6c757de6;top:0;bottom:0;z-index:5}.clip-segment{position:absolute;height:95%;top:0;border-radius:4px;background-size:cover;background-position:center;background-blend-mode:soft-light;box-shadow:0 2px 8px #0003;overflow:hidden;cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:box-shadow .2s,transform .1s;z-index:15}.clip-segment:nth-child(odd),.segment-color-1,.segment-color-3,.segment-color-5,.segment-color-7{background-color:transparent;border:2px solid rgba(0,123,255,.9)}.clip-segment:nth-child(2n),.segment-color-2,.segment-color-4,.segment-color-6,.segment-color-8{background-color:transparent;border:2px solid rgba(108,117,125,.9)}.clip-segment:hover{box-shadow:0 4px 12px #0000004d;transform:translateY(-1px);filter:brightness(1.1)}.clip-segment:active{cursor:grabbing;box-shadow:0 2px 6px #0000004d;transform:translateY(0)}.clip-segment.selected{border-width:3px;box-shadow:0 4px 12px #0006;z-index:25;filter:brightness(1.2)}.clip-segment-info{background-color:#e2e6eae6;color:#000;padding:6px 8px;font-size:.7rem;position:absolute;top:0;left:0;width:100%;border-radius:4px 4px 0 0;z-index:2;display:flex;flex-direction:column;gap:2px}.clip-segment-name{font-weight:700;color:#000}.clip-segment-time{font-size:.65rem;color:#000}.clip-segment-duration{font-size:.65rem;color:#000;background:#b3d9ff66;padding:1px 4px;border-radius:2px;display:inline-block;margin-top:2px}.clip-segment-handle{position:absolute;width:8px;top:0;bottom:0;background-color:#6c757de6;cursor:ew-resize;z-index:20;display:flex;align-items:center;justify-content:center}.clip-segment-handle:after{content:"↔";color:#fff;font-size:12px;text-shadow:0 0 2px rgba(0,0,0,.8)}.clip-segment-handle.left{left:0}.clip-segment-handle.right{right:0}.clip-segment-handle:hover{background-color:#007bffe6;width:10px}input[type=range]{-webkit-appearance:none;height:6px;background:#e0e0e0;border-radius:3px}input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;height:16px;width:16px;border-radius:50%;background:#007bffe6;cursor:pointer}[data-tooltip]{position:relative;cursor:pointer}[data-tooltip]:before{content:attr(data-tooltip);position:absolute;bottom:100%;left:50%;transform:translate(-50%);margin-bottom:8px;background-color:#000c;color:#fff;padding:5px 10px;border-radius:4px;font-size:.8rem;white-space:nowrap;z-index:1000;opacity:0;visibility:hidden;transition:opacity .2s,visibility .2s;pointer-events:none}[data-tooltip]:after{content:"";position:absolute;bottom:100%;left:50%;transform:translate(-50%);border-width:5px;border-style:solid;border-color:rgba(0,0,0,.8) transparent transparent transparent;margin-bottom:0;opacity:0;visibility:hidden;transition:opacity .2s,visibility .2s;pointer-events:none}[data-tooltip]:hover:before,[data-tooltip]:hover:after{opacity:1;visibility:visible}button[disabled][data-tooltip]:before,button[disabled][data-tooltip]:after{opacity:.5}.tooltip-action-btn{position:relative}.tooltip-action-btn[data-tooltip]:before,.tooltip-action-btn[data-tooltip]:after{opacity:0;visibility:hidden;position:absolute;pointer-events:none;transition:all .3s ease}.tooltip-action-btn[data-tooltip]:before{content:attr(data-tooltip);background-color:#000c;color:#fff;font-size:12px;padding:4px 8px;border-radius:3px;white-space:nowrap;bottom:-35px;left:50%;transform:translate(-50%);z-index:9999}.tooltip-action-btn[data-tooltip]:after{content:"";border-width:5px;border-style:solid;border-color:transparent transparent rgba(0,0,0,.8) transparent;bottom:-15px;left:50%;transform:translate(-50%);z-index:9999}.tooltip-action-btn:hover[data-tooltip]:before,.tooltip-action-btn:hover[data-tooltip]:after{opacity:1;visibility:visible}.segment-tooltip{background-color:#b3d9fff2;color:#000;border-radius:4px;padding:6px;min-width:140px;z-index:1000;box-shadow:0 3px 10px #0003}.segment-tooltip:after{content:"";position:absolute;bottom:-6px;left:50%;transform:translate(-50%);width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid rgba(179,217,255,.95)}.tooltip-time{font-size:.85rem;font-weight:700;text-align:center;margin-bottom:6px;color:#000}.tooltip-actions{display:flex;justify-content:space-between;gap:5px;position:relative}.tooltip-action-btn{background-color:#007bff33;border:none;border-radius:3px;width:30px;height:30px;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:6px;transition:background-color .2s}.tooltip-action-btn:hover{background-color:#007bff66}.tooltip-action-btn svg{width:100%;height:100%;stroke:currentColor}.tooltip-action-btn.set-in svg,.tooltip-action-btn.set-out svg{width:100%;height:100%;margin:0 auto;fill:currentColor;stroke:none}.empty-space-tooltip{background-color:#fff;border-radius:6px;box-shadow:0 2px 8px #00000026;padding:8px;z-index:50;min-width:120px;text-align:center;position:relative}.empty-space-tooltip:after{content:"";position:absolute;bottom:-8px;left:50%;transform:translate(-50%);border-width:8px 8px 0;border-style:solid;border-color:white transparent transparent}.tooltip-action-btn.new-segment{width:auto;padding:6px 10px;display:flex;align-items:center;gap:5px}.tooltip-btn-text{font-size:.8rem;white-space:nowrap;color:#000}.icon-new-segment{width:20px;height:20px}.zoom-dropdown-container{position:relative}.zoom-button{display:flex;align-items:center;gap:6px;background-color:#6c757dcc;color:#fff;border:none;border-radius:4px;padding:8px 12px;font-weight:500;cursor:pointer;transition:background-color .2s}.zoom-button:hover{background-color:#6c757d}.zoom-dropdown{background-color:#fff;border-radius:4px;box-shadow:0 2px 10px #00000026;max-height:300px;overflow-y:auto}.zoom-option{padding:8px 12px;cursor:pointer;display:flex;align-items:center;gap:5px}.zoom-option:hover{background-color:#007bff1a}.zoom-option.selected{background-color:#007bff33;font-weight:500}.save-button,.save-copy-button{background-color:#007bffcc;color:#fff;border:none;border-radius:4px;padding:8px 12px;font-weight:500;cursor:pointer;transition:background-color .2s}.save-button:hover,.save-copy-button:hover{background-color:#007bff}.save-copy-button{background-color:#6c757dcc}.save-copy-button:hover{background-color:#6c757d}.time-nav-label{font-weight:500;font-size:.9rem}.time-input{padding:6px 10px;border-radius:4px;border:1px solid #ccc;width:150px;font-family:monospace}.time-button-group{display:flex;gap:5px}.time-button{background-color:#6c757dcc;color:#fff;border:none;border-radius:4px;padding:6px 8px;font-size:.8rem;cursor:pointer;transition:background-color .2s}.time-button:hover{background-color:#6c757d}.timeline-controls{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;padding:12px;background-color:#f5f5f5;border-radius:6px;margin-top:15px}.time-navigation{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.controls-right{display:flex;align-items:center;gap:10px}@media (max-width: 768px){.timeline-controls{flex-direction:column;align-items:flex-start;gap:15px}.controls-right{margin-top:10px;width:100%;justify-content:flex-start}}.timeline-header{display:flex;align-items:center;gap:20px;margin-bottom:10px;flex-wrap:wrap}.timeline-title{font-weight:700;margin-right:20px}.timeline-title-text{font-size:1.1rem}.current-time,.duration-time{white-space:nowrap}.time-code{font-family:monospace;font-weight:500}@media (max-width: 480px){.timeline-header{flex-direction:column;align-items:flex-start;gap:8px}.time-navigation{width:100%;flex-direction:column;align-items:flex-start;gap:10px}.time-button-group{width:100%;display:flex;justify-content:space-between;margin-top:10px}.controls-right{flex-wrap:wrap;gap:8px}.save-button,.save-copy-button{margin-top:8px;width:100%}.zoom-dropdown-container{width:100%}.zoom-button{width:100%;justify-content:center}}
diff --git a/frontend-tools/static/video_editor/video-editor.js b/frontend-tools/static/video_editor/video-editor.js
deleted file mode 100644
index b1d27d51..00000000
--- a/frontend-tools/static/video_editor/video-editor.js
+++ /dev/null
@@ -1,203 +0,0 @@
-(function(){"use strict";var ch={exports:{}},Ic={exports:{}},Wo={exports:{}};Wo.exports;var fh;function kS(){return fh||(fh=1,function(_,D){/**
- * @license React
- * react.development.js
- *
- * Copyright (c) Facebook, Inc. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- */(function(){typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart=="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(new Error);var se="18.3.1",ve=Symbol.for("react.element"),te=Symbol.for("react.portal"),pe=Symbol.for("react.fragment"),d=Symbol.for("react.strict_mode"),Ae=Symbol.for("react.profiler"),ne=Symbol.for("react.provider"),$=Symbol.for("react.context"),Ie=Symbol.for("react.forward_ref"),H=Symbol.for("react.suspense"),Te=Symbol.for("react.suspense_list"),G=Symbol.for("react.memo"),Le=Symbol.for("react.lazy"),Oe=Symbol.for("react.offscreen"),At=Symbol.iterator,Mt="@@iterator";function he(s){if(s===null||typeof s!="object")return null;var v=At&&s[At]||s[Mt];return typeof v=="function"?v:null}var oe={current:null},et={transition:null},ae={current:null,isBatchingLegacy:!1,didScheduleLegacyUpdate:!1},tt={current:null},ee={},Ke=null;function He(s){Ke=s}ee.setExtraStackFrame=function(s){Ke=s},ee.getCurrentStack=null,ee.getStackAddendum=function(){var s="";Ke&&(s+=Ke);var v=ee.getCurrentStack;return v&&(s+=v()||""),s};var Ct=!1,Je=!1,ge=!1,Re=!1,Et=!1,nt={ReactCurrentDispatcher:oe,ReactCurrentBatchConfig:et,ReactCurrentOwner:tt};nt.ReactDebugCurrentFrame=ee,nt.ReactCurrentActQueue=ae;function Lt(s){{for(var v=arguments.length,C=new Array(v>1?v-1:0),T=1;T1?v-1:0),T=1;T1){for(var Dt=Array(yt),_t=0;_t1){for(var Fe=Array(_t),Vt=0;Vt<_t;Vt++)Fe[Vt]=arguments[Vt+2];U.children=Fe}return K(s.type,ue,I,Ce,Ge,yt,U)}function ht(s){return typeof s=="object"&&s!==null&&s.$$typeof===ve}var qe=".",sn=":";function dt(s){var v=/[=:]/g,C={"=":"=0",":":"=2"},T=s.replace(v,function(U){return C[U]});return"$"+T}var mt=!1,wt=/\/+/g;function Tr(s){return s.replace(wt,"$&/")}function ga(s,v){return typeof s=="object"&&s!==null&&s.key!=null?(J(s.key),dt(""+s.key)):v.toString(36)}function ba(s,v,C,T,U){var ue=typeof s;(ue==="undefined"||ue==="boolean")&&(s=null);var I=!1;if(s===null)I=!0;else switch(ue){case"string":case"number":I=!0;break;case"object":switch(s.$$typeof){case ve:case te:I=!0}}if(I){var Ce=s,Ge=U(Ce),yt=T===""?qe+ga(Ce,0):T;if(ct(Ge)){var Dt="";yt!=null&&(Dt=Tr(yt)+"/"),ba(Ge,v,Dt,"",function(uf){return uf})}else Ge!=null&&(ht(Ge)&&(Ge.key&&(!Ce||Ce.key!==Ge.key)&&J(Ge.key),Ge=rt(Ge,C+(Ge.key&&(!Ce||Ce.key!==Ge.key)?Tr(""+Ge.key)+"/":"")+yt)),v.push(Ge));return 1}var _t,Fe,Vt=0,Xt=T===""?qe:T+sn;if(ct(s))for(var ui=0;ui is not supported and will be removed in a future major release. Did you mean to render instead?")),v.Provider},set:function(I){v.Provider=I}},_currentValue:{get:function(){return v._currentValue},set:function(I){v._currentValue=I}},_currentValue2:{get:function(){return v._currentValue2},set:function(I){v._currentValue2=I}},_threadCount:{get:function(){return v._threadCount},set:function(I){v._threadCount=I}},Consumer:{get:function(){return C||(C=!0,Me("Rendering is not supported and will be removed in a future major release. Did you mean to render instead?")),v.Consumer}},displayName:{get:function(){return v.displayName},set:function(I){U||(Lt("Setting `displayName` on Context.Consumer has no effect. You should set it directly on the context with Context.displayName = '%s'.",I),U=!0)}}}),v.Consumer=ue}return v._currentRenderer=null,v._currentRenderer2=null,v}var Sa=-1,Zn=0,ea=1,Fa=2;function ei(s){if(s._status===Sa){var v=s._result,C=v();if(C.then(function(ue){if(s._status===Zn||s._status===Sa){var I=s;I._status=ea,I._result=ue}},function(ue){if(s._status===Zn||s._status===Sa){var I=s;I._status=Fa,I._result=ue}}),s._status===Sa){var T=s;T._status=Zn,T._result=C}}if(s._status===ea){var U=s._result;return U===void 0&&Me(`lazy: Expected the result of a dynamic import() call. Instead received: %s
-
-Your code should look like:
- const MyComponent = lazy(() => import('./MyComponent'))
-
-Did you accidentally put curly braces around the import?`,U),"default"in U||Me(`lazy: Expected the result of a dynamic import() call. Instead received: %s
-
-Your code should look like:
- const MyComponent = lazy(() => import('./MyComponent'))`,U),U.default}else throw s._result}function ti(s){var v={_status:Sa,_result:s},C={$$typeof:Le,_payload:v,_init:ei};{var T,U;Object.defineProperties(C,{defaultProps:{configurable:!0,get:function(){return T},set:function(ue){Me("React.lazy(...): It is not supported to assign `defaultProps` to a lazy component import. Either specify them where the component is defined, or create a wrapping component around it."),T=ue,Object.defineProperty(C,"defaultProps",{enumerable:!0})}},propTypes:{configurable:!0,get:function(){return U},set:function(ue){Me("React.lazy(...): It is not supported to assign `propTypes` to a lazy component import. Either specify them where the component is defined, or create a wrapping component around it."),U=ue,Object.defineProperty(C,"propTypes",{enumerable:!0})}}})}return C}function ni(s){s!=null&&s.$$typeof===G?Me("forwardRef requires a render function but received a `memo` component. Instead of forwardRef(memo(...)), use memo(forwardRef(...))."):typeof s!="function"?Me("forwardRef requires a render function but was given %s.",s===null?"null":typeof s):s.length!==0&&s.length!==2&&Me("forwardRef render functions accept exactly two parameters: props and ref. %s",s.length===1?"Did you forget to use the ref parameter?":"Any additional parameter will be undefined."),s!=null&&(s.defaultProps!=null||s.propTypes!=null)&&Me("forwardRef render functions do not support propTypes or defaultProps. Did you accidentally pass a React component?");var v={$$typeof:Ie,render:s};{var C;Object.defineProperty(v,"displayName",{enumerable:!1,configurable:!0,get:function(){return C},set:function(T){C=T,!s.name&&!s.displayName&&(s.displayName=T)}})}return v}var p;p=Symbol.for("react.module.reference");function O(s){return!!(typeof s=="string"||typeof s=="function"||s===pe||s===Ae||Et||s===d||s===H||s===Te||Re||s===Oe||Ct||Je||ge||typeof s=="object"&&s!==null&&(s.$$typeof===Le||s.$$typeof===G||s.$$typeof===ne||s.$$typeof===$||s.$$typeof===Ie||s.$$typeof===p||s.getModuleId!==void 0))}function V(s,v){O(s)||Me("memo: The first argument must be a component. Instead received: %s",s===null?"null":typeof s);var C={$$typeof:G,type:s,compare:v===void 0?null:v};{var T;Object.defineProperty(C,"displayName",{enumerable:!1,configurable:!0,get:function(){return T},set:function(U){T=U,!s.name&&!s.displayName&&(s.displayName=U)}})}return C}function Q(){var s=oe.current;return s===null&&Me(`Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
-1. You might have mismatching versions of React and the renderer (such as React DOM)
-2. You might be breaking the Rules of Hooks
-3. You might have more than one copy of React in the same app
-See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.`),s}function $e(s){var v=Q();if(s._context!==void 0){var C=s._context;C.Consumer===s?Me("Calling useContext(Context.Consumer) is not supported, may cause bugs, and will be removed in a future major release. Did you mean to call useContext(Context) instead?"):C.Provider===s&&Me("Calling useContext(Context.Provider) is not supported. Did you mean to call useContext(Context) instead?")}return v.useContext(s)}function _e(s){var v=Q();return v.useState(s)}function Pe(s,v,C){var T=Q();return T.useReducer(s,v,C)}function ze(s){var v=Q();return v.useRef(s)}function Qt(s,v){var C=Q();return C.useEffect(s,v)}function Nt(s,v){var C=Q();return C.useInsertionEffect(s,v)}function zt(s,v){var C=Q();return C.useLayoutEffect(s,v)}function wn(s,v){var C=Q();return C.useCallback(s,v)}function Ca(s,v){var C=Q();return C.useMemo(s,v)}function Ea(s,v,C){var T=Q();return T.useImperativeHandle(s,v,C)}function Ht(s,v){{var C=Q();return C.useDebugValue(s,v)}}function Ne(){var s=Q();return s.useTransition()}function er(s){var v=Q();return v.useDeferredValue(s)}function Yi(){var s=Q();return s.useId()}function Vu(s,v,C){var T=Q();return T.useSyncExternalStore(s,v,C)}var xr=0,Xo,Io,Ko,Jo,Zo,Bu,$u;function qi(){}qi.__reactDisabledLog=!0;function el(){{if(xr===0){Xo=console.log,Io=console.info,Ko=console.warn,Jo=console.error,Zo=console.group,Bu=console.groupCollapsed,$u=console.groupEnd;var s={configurable:!0,enumerable:!0,value:qi,writable:!0};Object.defineProperties(console,{info:s,log:s,warn:s,error:s,group:s,groupCollapsed:s,groupEnd:s})}xr++}}function Va(){{if(xr--,xr===0){var s={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:Pt({},s,{value:Xo}),info:Pt({},s,{value:Io}),warn:Pt({},s,{value:Ko}),error:Pt({},s,{value:Jo}),group:Pt({},s,{value:Zo}),groupCollapsed:Pt({},s,{value:Bu}),groupEnd:Pt({},s,{value:$u})})}xr<0&&Me("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}}var ai=nt.ReactCurrentDispatcher,wr;function Gi(s,v,C){{if(wr===void 0)try{throw Error()}catch(U){var T=U.stack.trim().match(/\n( *(at )?)/);wr=T&&T[1]||""}return`
-`+wr+s}}var ri=!1,Wi;{var tl=typeof WeakMap=="function"?WeakMap:Map;Wi=new tl}function Pu(s,v){if(!s||ri)return"";{var C=Wi.get(s);if(C!==void 0)return C}var T;ri=!0;var U=Error.prepareStackTrace;Error.prepareStackTrace=void 0;var ue;ue=ai.current,ai.current=null,el();try{if(v){var I=function(){throw Error()};if(Object.defineProperty(I.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(I,[])}catch(Xt){T=Xt}Reflect.construct(s,[],I)}else{try{I.call()}catch(Xt){T=Xt}s.call(I.prototype)}}else{try{throw Error()}catch(Xt){T=Xt}s()}}catch(Xt){if(Xt&&T&&typeof Xt.stack=="string"){for(var Ce=Xt.stack.split(`
-`),Ge=T.stack.split(`
-`),yt=Ce.length-1,Dt=Ge.length-1;yt>=1&&Dt>=0&&Ce[yt]!==Ge[Dt];)Dt--;for(;yt>=1&&Dt>=0;yt--,Dt--)if(Ce[yt]!==Ge[Dt]){if(yt!==1||Dt!==1)do if(yt--,Dt--,Dt<0||Ce[yt]!==Ge[Dt]){var _t=`
-`+Ce[yt].replace(" at new "," at ");return s.displayName&&_t.includes("")&&(_t=_t.replace("",s.displayName)),typeof s=="function"&&Wi.set(s,_t),_t}while(yt>=1&&Dt>=0);break}}}finally{ri=!1,ai.current=ue,Va(),Error.prepareStackTrace=U}var Fe=s?s.displayName||s.name:"",Vt=Fe?Gi(Fe):"";return typeof s=="function"&&Wi.set(s,Vt),Vt}function nl(s,v,C){return Pu(s,!1)}function tf(s){var v=s.prototype;return!!(v&&v.isReactComponent)}function ii(s,v,C){if(s==null)return"";if(typeof s=="function")return Pu(s,tf(s));if(typeof s=="string")return Gi(s);switch(s){case H:return Gi("Suspense");case Te:return Gi("SuspenseList")}if(typeof s=="object")switch(s.$$typeof){case Ie:return nl(s.render);case G:return ii(s.type,v,C);case Le:{var T=s,U=T._payload,ue=T._init;try{return ii(ue(U),v,C)}catch{}}}return""}var Yu={},al=nt.ReactDebugCurrentFrame;function ut(s){if(s){var v=s._owner,C=ii(s.type,s._source,v?v.type:null);al.setExtraStackFrame(C)}else al.setExtraStackFrame(null)}function nf(s,v,C,T,U){{var ue=Function.call.bind(fe);for(var I in s)if(ue(s,I)){var Ce=void 0;try{if(typeof s[I]!="function"){var Ge=Error((T||"React class")+": "+C+" type `"+I+"` is invalid; it must be a function, usually from the `prop-types` package, but received `"+typeof s[I]+"`.This often happens because of typos such as `PropTypes.function` instead of `PropTypes.func`.");throw Ge.name="Invariant Violation",Ge}Ce=s[I](v,I,T,C,null,"SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED")}catch(yt){Ce=yt}Ce&&!(Ce instanceof Error)&&(ut(U),Me("%s: type specification of %s `%s` is invalid; the type checker function must return `null` or an `Error` but returned a %s. You may have forgotten to pass an argument to the type checker creator (arrayOf, instanceOf, objectOf, oneOf, oneOfType, and shape all require an argument).",T||"React class",C,I,typeof Ce),ut(null)),Ce instanceof Error&&!(Ce.message in Yu)&&(Yu[Ce.message]=!0,ut(U),Me("Failed %s type: %s",C,Ce.message),ut(null))}}}function tr(s){if(s){var v=s._owner,C=ii(s.type,s._source,v?v.type:null);He(C)}else He(null)}var we;we=!1;function rl(){if(tt.current){var s=W(tt.current.type);if(s)return`
-
-Check the render method of \``+s+"`."}return""}function Un(s){if(s!==void 0){var v=s.fileName.replace(/^.*[\\\/]/,""),C=s.lineNumber;return`
-
-Check your code at `+v+":"+C+"."}return""}function oi(s){return s!=null?Un(s.__source):""}var Dr={};function af(s){var v=rl();if(!v){var C=typeof s=="string"?s:s.displayName||s.name;C&&(v=`
-
-Check the top-level render call using <`+C+">.")}return v}function cn(s,v){if(!(!s._store||s._store.validated||s.key!=null)){s._store.validated=!0;var C=af(v);if(!Dr[C]){Dr[C]=!0;var T="";s&&s._owner&&s._owner!==tt.current&&(T=" It was passed a child from "+W(s._owner.type)+"."),tr(s),Me('Each child in a list should have a unique "key" prop.%s%s See https://reactjs.org/link/warning-keys for more information.',C,T),tr(null)}}}function Ft(s,v){if(typeof s=="object"){if(ct(s))for(var C=0;C ",U=" Did you accidentally export a JSX literal instead of a component?"):I=typeof s,Me("React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: %s.%s",I,U)}var Ce=ye.apply(this,arguments);if(Ce==null)return Ce;if(T)for(var Ge=2;Ge10&&Lt("Detected a large number of updates inside startTransition. If this is due to a subscription please re-write it to use React provided hooks. Otherwise concurrent mode guarantees are off the table."),T._updatedFibers.clear()}}}var ol=!1,Qi=null;function of(s){if(Qi===null)try{var v=("require"+Math.random()).slice(0,7),C=_&&_[v];Qi=C.call(_,"timers").setImmediate}catch{Qi=function(U){ol===!1&&(ol=!0,typeof MessageChannel>"u"&&Me("This browser does not have a MessageChannel implementation, so enqueuing tasks via await act(async () => ...) will fail. Please file an issue at https://github.com/facebook/react/issues if you encounter this warning."));var ue=new MessageChannel;ue.port1.onmessage=U,ue.port2.postMessage(void 0)}}return Qi(s)}var _r=0,li=!1;function ll(s){{var v=_r;_r++,ae.current===null&&(ae.current=[]);var C=ae.isBatchingLegacy,T;try{if(ae.isBatchingLegacy=!0,T=s(),!C&&ae.didScheduleLegacyUpdate){var U=ae.current;U!==null&&(ae.didScheduleLegacyUpdate=!1,Ki(U))}}catch(Fe){throw nr(v),Fe}finally{ae.isBatchingLegacy=C}if(T!==null&&typeof T=="object"&&typeof T.then=="function"){var ue=T,I=!1,Ce={then:function(Fe,Vt){I=!0,ue.then(function(Xt){nr(v),_r===0?Xi(Xt,Fe,Vt):Fe(Xt)},function(Xt){nr(v),Vt(Xt)})}};return!li&&typeof Promise<"u"&&Promise.resolve().then(function(){}).then(function(){I||(li=!0,Me("You called act(async () => ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);"))}),Ce}else{var Ge=T;if(nr(v),_r===0){var yt=ae.current;yt!==null&&(Ki(yt),ae.current=null);var Dt={then:function(Fe,Vt){ae.current===null?(ae.current=[],Xi(Ge,Fe,Vt)):Fe(Ge)}};return Dt}else{var _t={then:function(Fe,Vt){Fe(Ge)}};return _t}}}}function nr(s){s!==_r-1&&Me("You seem to have overlapping act() calls, this is not supported. Be sure to await previous act() calls before making a new one. "),_r=s}function Xi(s,v,C){{var T=ae.current;if(T!==null)try{Ki(T),of(function(){T.length===0?(ae.current=null,v(s)):Xi(s,v,C)})}catch(U){C(U)}else v(s)}}var Ii=!1;function Ki(s){if(!Ii){Ii=!0;var v=0;try{for(;v1?O-1:0),Q=1;Q=1&&zt>=0&&ze[Nt]!==Qt[zt];)zt--;for(;Nt>=1&&zt>=0;Nt--,zt--)if(ze[Nt]!==Qt[zt]){if(Nt!==1||zt!==1)do if(Nt--,zt--,zt<0||ze[Nt]!==Qt[zt]){var wn=`
-`+ze[Nt].replace(" at new "," at ");return p.displayName&&wn.includes("")&&(wn=wn.replace("",p.displayName)),typeof p=="function"&&xe.set(p,wn),wn}while(Nt>=1&&zt>=0);break}}}finally{De=!1,Jn.current=_e,Yn(),Error.prepareStackTrace=$e}var Ca=p?p.displayName||p.name:"",Ea=Ca?N(Ca):"";return typeof p=="function"&&xe.set(p,Ea),Ea}function ct(p,O,V){return Ye(p,!1)}function kt(p){var O=p.prototype;return!!(O&&O.isReactComponent)}function at(p,O,V){if(p==null)return"";if(typeof p=="function")return Ye(p,kt(p));if(typeof p=="string")return N(p);switch(p){case $:return N("Suspense");case Ie:return N("SuspenseList")}if(typeof p=="object")switch(p.$$typeof){case ne:return ct(p.render);case H:return at(p.type,O,V);case Te:{var Q=p,$e=Q._payload,_e=Q._init;try{return at(_e($e),O,V)}catch{}}}return""}var L=Object.prototype.hasOwnProperty,J={},z=Mt.ReactDebugCurrentFrame;function ie(p){if(p){var O=p._owner,V=at(p.type,p._source,O?O.type:null);z.setExtraStackFrame(V)}else z.setExtraStackFrame(null)}function W(p,O,V,Q,$e){{var _e=Function.call.bind(L);for(var Pe in p)if(_e(p,Pe)){var ze=void 0;try{if(typeof p[Pe]!="function"){var Qt=Error((Q||"React class")+": "+V+" type `"+Pe+"` is invalid; it must be a function, usually from the `prop-types` package, but received `"+typeof p[Pe]+"`.This often happens because of typos such as `PropTypes.function` instead of `PropTypes.func`.");throw Qt.name="Invariant Violation",Qt}ze=p[Pe](O,Pe,Q,V,null,"SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED")}catch(Nt){ze=Nt}ze&&!(ze instanceof Error)&&(ie($e),he("%s: type specification of %s `%s` is invalid; the type checker function must return `null` or an `Error` but returned a %s. You may have forgotten to pass an argument to the type checker creator (arrayOf, instanceOf, objectOf, oneOf, oneOfType, and shape all require an argument).",Q||"React class",V,Pe,typeof ze),ie(null)),ze instanceof Error&&!(ze.message in J)&&(J[ze.message]=!0,ie($e),he("Failed %s type: %s",V,ze.message),ie(null))}}}var fe=Array.isArray;function je(p){return fe(p)}function ke(p){{var O=typeof Symbol=="function"&&Symbol.toStringTag,V=O&&p[Symbol.toStringTag]||p.constructor.name||"Object";return V}}function Ze(p){try{return lt(p),!1}catch{return!0}}function lt(p){return""+p}function xt(p){if(Ze(p))return he("The provided key is an unsupported type %s. This value must be coerced to a string before before using it here.",ke(p)),lt(p)}var ft=Mt.ReactCurrentOwner,Bt={key:!0,ref:!0,__self:!0,__source:!0},mn,A;function K(p){if(L.call(p,"ref")){var O=Object.getOwnPropertyDescriptor(p,"ref").get;if(O&&O.isReactWarning)return!1}return p.ref!==void 0}function ye(p){if(L.call(p,"key")){var O=Object.getOwnPropertyDescriptor(p,"key").get;if(O&&O.isReactWarning)return!1}return p.key!==void 0}function rt(p,O){typeof p.ref=="string"&&ft.current}function Tt(p,O){{var V=function(){mn||(mn=!0,he("%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)",O))};V.isReactWarning=!0,Object.defineProperty(p,"key",{get:V,configurable:!0})}}function ht(p,O){{var V=function(){A||(A=!0,he("%s: `ref` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)",O))};V.isReactWarning=!0,Object.defineProperty(p,"ref",{get:V,configurable:!0})}}var qe=function(p,O,V,Q,$e,_e,Pe){var ze={$$typeof:D,type:p,key:O,ref:V,props:Pe,_owner:_e};return ze._store={},Object.defineProperty(ze._store,"validated",{configurable:!1,enumerable:!1,writable:!0,value:!1}),Object.defineProperty(ze,"_self",{configurable:!1,enumerable:!1,writable:!1,value:Q}),Object.defineProperty(ze,"_source",{configurable:!1,enumerable:!1,writable:!1,value:$e}),Object.freeze&&(Object.freeze(ze.props),Object.freeze(ze)),ze};function sn(p,O,V,Q,$e){{var _e,Pe={},ze=null,Qt=null;V!==void 0&&(xt(V),ze=""+V),ye(O)&&(xt(O.key),ze=""+O.key),K(O)&&(Qt=O.ref,rt(O,$e));for(_e in O)L.call(O,_e)&&!Bt.hasOwnProperty(_e)&&(Pe[_e]=O[_e]);if(p&&p.defaultProps){var Nt=p.defaultProps;for(_e in Nt)Pe[_e]===void 0&&(Pe[_e]=Nt[_e])}if(ze||Qt){var zt=typeof p=="function"?p.displayName||p.name||"Unknown":p;ze&&Tt(Pe,zt),Qt&&ht(Pe,zt)}return qe(p,ze,Qt,$e,Q,ft.current,Pe)}}var dt=Mt.ReactCurrentOwner,mt=Mt.ReactDebugCurrentFrame;function wt(p){if(p){var O=p._owner,V=at(p.type,p._source,O?O.type:null);mt.setExtraStackFrame(V)}else mt.setExtraStackFrame(null)}var Tr;Tr=!1;function ga(p){return typeof p=="object"&&p!==null&&p.$$typeof===D}function ba(){{if(dt.current){var p=Re(dt.current.type);if(p)return`
-
-Check the render method of \``+p+"`."}return""}}function Rr(p){return""}var Vi={};function Bi(p){{var O=ba();if(!O){var V=typeof p=="string"?p:p.displayName||p.name;V&&(O=`
-
-Check the top-level render call using <`+V+">.")}return O}}function Zr(p,O){{if(!p._store||p._store.validated||p.key!=null)return;p._store.validated=!0;var V=Bi(O);if(Vi[V])return;Vi[V]=!0;var Q="";p&&p._owner&&p._owner!==dt.current&&(Q=" It was passed a child from "+Re(p._owner.type)+"."),wt(p),he('Each child in a list should have a unique "key" prop.%s%s See https://reactjs.org/link/warning-keys for more information.',V,Q),wt(null)}}function $i(p,O){{if(typeof p!="object")return;if(je(p))for(var V=0;V ",ze=" Did you accidentally export a JSX literal instead of a component?"):Nt=typeof p,he("React.jsx: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: %s.%s",Nt,ze)}var zt=sn(p,O,V,$e,_e);if(zt==null)return zt;if(Pe){var wn=O.children;if(wn!==void 0)if(Q)if(je(wn)){for(var Ca=0;Ca0?"{key: someKey, "+Ht.join(": ..., ")+": ...}":"{key: someKey}";if(!Zn[Ea+Ne]){var er=Ht.length>0?"{"+Ht.join(": ..., ")+": ...}":"{}";he(`A props object containing a "key" prop is being spread into JSX:
- let props = %s;
- <%s {...props} />
-React keys must be passed directly to JSX without using spread:
- let props = %s;
- <%s key={someKey} {...props} />`,Ne,Ea,er,Ea),Zn[Ea+Ne]=!0}}return p===ve?Sa(zt):Pi(zt),zt}}function Fa(p,O,V){return ea(p,O,V,!0)}function ei(p,O,V){return ea(p,O,V,!1)}var ti=ei,ni=Fa;Qo.Fragment=ve,Qo.jsx=ti,Qo.jsxs=ni}(),Qo}ch.exports=NS();var h=ch.exports,ph={exports:{}},Jc={exports:{}},Zc={},hh;function US(){return hh||(hh=1,function(_){/**
- * @license React
- * scheduler.development.js
- *
- * Copyright (c) Facebook, Inc. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- */(function(){typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart=="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(new Error);var D=!1,se=5;function ve(A,K){var ye=A.length;A.push(K),d(A,K,ye)}function te(A){return A.length===0?null:A[0]}function pe(A){if(A.length===0)return null;var K=A[0],ye=A.pop();return ye!==K&&(A[0]=ye,Ae(A,ye,0)),K}function d(A,K,ye){for(var rt=ye;rt>0;){var Tt=rt-1>>>1,ht=A[Tt];if(ne(ht,K)>0)A[Tt]=K,A[rt]=ht,rt=Tt;else return}}function Ae(A,K,ye){for(var rt=ye,Tt=A.length,ht=Tt>>>1;rtye&&(!A||z()));){var rt=ge.callback;if(typeof rt=="function"){ge.callback=null,Re=ge.priorityLevel;var Tt=ge.expirationTime<=ye,ht=rt(Tt);ye=_.unstable_now(),typeof ht=="function"?ge.callback=ht:ge===te(He)&&pe(He),un(ye)}else pe(He);ge=te(He)}if(ge!==null)return!0;var qe=te(Ct);return qe!==null&&xt(Pn,qe.startTime-ye),!1}function Jt(A,K){switch(A){case $:case Ie:case H:case Te:case G:break;default:A=H}var ye=Re;Re=A;try{return K()}finally{Re=ye}}function Yn(A){var K;switch(Re){case $:case Ie:case H:K=H;break;default:K=Re;break}var ye=Re;Re=K;try{return A()}finally{Re=ye}}function Jn(A){var K=Re;return function(){var ye=Re;Re=K;try{return A.apply(this,arguments)}finally{Re=ye}}}function Wt(A,K,ye){var rt=_.unstable_now(),Tt;if(typeof ye=="object"&&ye!==null){var ht=ye.delay;typeof ht=="number"&&ht>0?Tt=rt+ht:Tt=rt}else Tt=rt;var qe;switch(A){case $:qe=et;break;case Ie:qe=ae;break;case G:qe=Ke;break;case Te:qe=ee;break;case H:default:qe=tt;break}var sn=Tt+qe,dt={id:Je++,callback:K,priorityLevel:A,startTime:Tt,expirationTime:sn,sortIndex:-1};return Tt>rt?(dt.sortIndex=Tt,ve(Ct,dt),te(He)===null&&dt===te(Ct)&&(Lt?ft():Lt=!0,xt(Pn,Tt-rt))):(dt.sortIndex=sn,ve(He,dt),!nt&&!Et&&(nt=!0,lt(Pt))),dt}function N(){}function De(){!nt&&!Et&&(nt=!0,lt(Pt))}function xe(){return te(He)}function me(A){A.callback=null}function Ye(){return Re}var ct=!1,kt=null,at=-1,L=se,J=-1;function z(){var A=_.unstable_now()-J;return!(A125){console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported");return}A>0?L=Math.floor(1e3/A):L=se}var fe=function(){if(kt!==null){var A=_.unstable_now();J=A;var K=!0,ye=!0;try{ye=kt(K,A)}finally{ye?je():(ct=!1,kt=null)}}else ct=!1},je;if(typeof Kn=="function")je=function(){Kn(fe)};else if(typeof MessageChannel<"u"){var ke=new MessageChannel,Ze=ke.port2;ke.port1.onmessage=fe,je=function(){Ze.postMessage(null)}}else je=function(){Me(fe,0)};function lt(A){kt=A,ct||(ct=!0,je())}function xt(A,K){at=Me(function(){A(_.unstable_now())},K)}function ft(){jt(at),at=-1}var Bt=ie,mn=null;_.unstable_IdlePriority=G,_.unstable_ImmediatePriority=$,_.unstable_LowPriority=Te,_.unstable_NormalPriority=H,_.unstable_Profiling=mn,_.unstable_UserBlockingPriority=Ie,_.unstable_cancelCallback=me,_.unstable_continueExecution=De,_.unstable_forceFrameRate=W,_.unstable_getCurrentPriorityLevel=Ye,_.unstable_getFirstCallbackNode=xe,_.unstable_next=Yn,_.unstable_pauseExecution=N,_.unstable_requestPaint=Bt,_.unstable_runWithPriority=Jt,_.unstable_scheduleCallback=Wt,_.unstable_shouldYield=z,_.unstable_wrapCallback=Jn,typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop=="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(new Error)})()}(Zc)),Zc}var mh;function AS(){return mh||(mh=1,Jc.exports=US()),Jc.exports}var $n={},yh;function jS(){if(yh)return $n;yh=1;/**
- * @license React
- * react-dom.development.js
- *
- * Copyright (c) Facebook, Inc. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- */return function(){typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart=="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(new Error);var _=Kc(),D=AS(),se=_.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,ve=!1;function te(e){ve=e}function pe(e){if(!ve){for(var t=arguments.length,n=new Array(t>1?t-1:0),a=1;a1?t-1:0),a=1;a2&&(e[0]==="o"||e[0]==="O")&&(e[1]==="n"||e[1]==="N")}function qe(e,t,n,a){if(n!==null&&n.type===je)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":{if(a)return!1;if(n!==null)return!n.acceptsBooleans;var r=e.toLowerCase().slice(0,5);return r!=="data-"&&r!=="aria-"}default:return!1}}function sn(e,t,n,a){if(t===null||typeof t>"u"||qe(e,t,n,a))return!0;if(a)return!1;if(n!==null)switch(n.type){case lt:return!t;case xt:return t===!1;case ft:return isNaN(t);case Bt:return isNaN(t)||t<1}return!1}function dt(e){return wt.hasOwnProperty(e)?wt[e]:null}function mt(e,t,n,a,r,i,o){this.acceptsBooleans=t===Ze||t===lt||t===xt,this.attributeName=a,this.attributeNamespace=r,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=o}var wt={},Tr=["children","dangerouslySetInnerHTML","defaultValue","defaultChecked","innerHTML","suppressContentEditableWarning","suppressHydrationWarning","style"];Tr.forEach(function(e){wt[e]=new mt(e,je,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0],n=e[1];wt[t]=new mt(t,ke,!1,n,null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){wt[e]=new mt(e,Ze,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){wt[e]=new mt(e,Ze,!1,e,null,!1,!1)}),["allowFullScreen","async","autoFocus","autoPlay","controls","default","defer","disabled","disablePictureInPicture","disableRemotePlayback","formNoValidate","hidden","loop","noModule","noValidate","open","playsInline","readOnly","required","reversed","scoped","seamless","itemScope"].forEach(function(e){wt[e]=new mt(e,lt,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){wt[e]=new mt(e,lt,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){wt[e]=new mt(e,xt,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){wt[e]=new mt(e,Bt,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){wt[e]=new mt(e,ft,!1,e.toLowerCase(),null,!1,!1)});var ga=/[\-\:]([a-z])/g,ba=function(e){return e[1].toUpperCase()};["accent-height","alignment-baseline","arabic-form","baseline-shift","cap-height","clip-path","clip-rule","color-interpolation","color-interpolation-filters","color-profile","color-rendering","dominant-baseline","enable-background","fill-opacity","fill-rule","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","glyph-name","glyph-orientation-horizontal","glyph-orientation-vertical","horiz-adv-x","horiz-origin-x","image-rendering","letter-spacing","lighting-color","marker-end","marker-mid","marker-start","overline-position","overline-thickness","paint-order","panose-1","pointer-events","rendering-intent","shape-rendering","stop-color","stop-opacity","strikethrough-position","strikethrough-thickness","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke-width","text-anchor","text-decoration","text-rendering","underline-position","underline-thickness","unicode-bidi","unicode-range","units-per-em","v-alphabetic","v-hanging","v-ideographic","v-mathematical","vector-effect","vert-adv-y","vert-origin-x","vert-origin-y","word-spacing","writing-mode","xmlns:xlink","x-height"].forEach(function(e){var t=e.replace(ga,ba);wt[t]=new mt(t,ke,!1,e,null,!1,!1)}),["xlink:actuate","xlink:arcrole","xlink:role","xlink:show","xlink:title","xlink:type"].forEach(function(e){var t=e.replace(ga,ba);wt[t]=new mt(t,ke,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(ga,ba);wt[t]=new mt(t,ke,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){wt[e]=new mt(e,ke,!1,e.toLowerCase(),null,!1,!1)});var Rr="xlinkHref";wt[Rr]=new mt("xlinkHref",ke,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){wt[e]=new mt(e,ke,!1,e.toLowerCase(),null,!0,!0)});var Vi=/^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*\:/i,Bi=!1;function Zr(e){!Bi&&Vi.test(e)&&(Bi=!0,d("A future version of React will block javascript: URLs as a security precaution. Use event handlers instead if you can. If you need to generate unsafe HTML try using dangerouslySetInnerHTML instead. React was passed %s.",JSON.stringify(e)))}function $i(e,t,n,a){if(a.mustUseProperty){var r=a.propertyName;return e[r]}else{L(n,t),a.sanitizeURL&&Zr(""+n);var i=a.attributeName,o=null;if(a.type===xt){if(e.hasAttribute(i)){var l=e.getAttribute(i);return l===""?!0:sn(t,n,a,!1)?l:l===""+n?n:l}}else if(e.hasAttribute(i)){if(sn(t,n,a,!1))return e.getAttribute(i);if(a.type===lt)return n;o=e.getAttribute(i)}return sn(t,n,a,!1)?o===null?n:o:o===""+n?n:o}}function Pi(e,t,n,a){{if(!Tt(t))return;if(!e.hasAttribute(t))return n===void 0?void 0:null;var r=e.getAttribute(t);return L(n,t),r===""+n?n:r}}function Sa(e,t,n,a){var r=dt(t);if(!ht(t,r,a)){if(sn(t,n,r,a)&&(n=null),a||r===null){if(Tt(t)){var i=t;n===null?e.removeAttribute(i):(L(n,t),e.setAttribute(i,""+n))}return}var o=r.mustUseProperty;if(o){var l=r.propertyName;if(n===null){var u=r.type;e[l]=u===lt?!1:""}else e[l]=n;return}var c=r.attributeName,f=r.attributeNamespace;if(n===null)e.removeAttribute(c);else{var y=r.type,m;y===lt||y===xt&&n===!0?m="":(L(n,c),m=""+n,r.sanitizeURL&&Zr(m.toString())),f?e.setAttributeNS(f,c,m):e.setAttribute(c,m)}}}var Zn=Symbol.for("react.element"),ea=Symbol.for("react.portal"),Fa=Symbol.for("react.fragment"),ei=Symbol.for("react.strict_mode"),ti=Symbol.for("react.profiler"),ni=Symbol.for("react.provider"),p=Symbol.for("react.context"),O=Symbol.for("react.forward_ref"),V=Symbol.for("react.suspense"),Q=Symbol.for("react.suspense_list"),$e=Symbol.for("react.memo"),_e=Symbol.for("react.lazy"),Pe=Symbol.for("react.scope"),ze=Symbol.for("react.debug_trace_mode"),Qt=Symbol.for("react.offscreen"),Nt=Symbol.for("react.legacy_hidden"),zt=Symbol.for("react.cache"),wn=Symbol.for("react.tracing_marker"),Ca=Symbol.iterator,Ea="@@iterator";function Ht(e){if(e===null||typeof e!="object")return null;var t=Ca&&e[Ca]||e[Ea];return typeof t=="function"?t:null}var Ne=Object.assign,er=0,Yi,Vu,xr,Xo,Io,Ko,Jo;function Zo(){}Zo.__reactDisabledLog=!0;function Bu(){{if(er===0){Yi=console.log,Vu=console.info,xr=console.warn,Xo=console.error,Io=console.group,Ko=console.groupCollapsed,Jo=console.groupEnd;var e={configurable:!0,enumerable:!0,value:Zo,writable:!0};Object.defineProperties(console,{info:e,log:e,warn:e,error:e,group:e,groupCollapsed:e,groupEnd:e})}er++}}function $u(){{if(er--,er===0){var e={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:Ne({},e,{value:Yi}),info:Ne({},e,{value:Vu}),warn:Ne({},e,{value:xr}),error:Ne({},e,{value:Xo}),group:Ne({},e,{value:Io}),groupCollapsed:Ne({},e,{value:Ko}),groupEnd:Ne({},e,{value:Jo})})}er<0&&d("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}}var qi=se.ReactCurrentDispatcher,el;function Va(e,t,n){{if(el===void 0)try{throw Error()}catch(r){var a=r.stack.trim().match(/\n( *(at )?)/);el=a&&a[1]||""}return`
-`+el+e}}var ai=!1,wr;{var Gi=typeof WeakMap=="function"?WeakMap:Map;wr=new Gi}function ri(e,t){if(!e||ai)return"";{var n=wr.get(e);if(n!==void 0)return n}var a;ai=!0;var r=Error.prepareStackTrace;Error.prepareStackTrace=void 0;var i;i=qi.current,qi.current=null,Bu();try{if(t){var o=function(){throw Error()};if(Object.defineProperty(o.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(o,[])}catch(E){a=E}Reflect.construct(e,[],o)}else{try{o.call()}catch(E){a=E}e.call(o.prototype)}}else{try{throw Error()}catch(E){a=E}e()}}catch(E){if(E&&a&&typeof E.stack=="string"){for(var l=E.stack.split(`
-`),u=a.stack.split(`
-`),c=l.length-1,f=u.length-1;c>=1&&f>=0&&l[c]!==u[f];)f--;for(;c>=1&&f>=0;c--,f--)if(l[c]!==u[f]){if(c!==1||f!==1)do if(c--,f--,f<0||l[c]!==u[f]){var y=`
-`+l[c].replace(" at new "," at ");return e.displayName&&y.includes("")&&(y=y.replace("",e.displayName)),typeof e=="function"&&wr.set(e,y),y}while(c>=1&&f>=0);break}}}finally{ai=!1,qi.current=i,$u(),Error.prepareStackTrace=r}var m=e?e.displayName||e.name:"",S=m?Va(m):"";return typeof e=="function"&&wr.set(e,S),S}function Wi(e,t,n){return ri(e,!0)}function tl(e,t,n){return ri(e,!1)}function Pu(e){var t=e.prototype;return!!(t&&t.isReactComponent)}function nl(e,t,n){if(e==null)return"";if(typeof e=="function")return ri(e,Pu(e));if(typeof e=="string")return Va(e);switch(e){case V:return Va("Suspense");case Q:return Va("SuspenseList")}if(typeof e=="object")switch(e.$$typeof){case O:return tl(e.render);case $e:return nl(e.type,t,n);case _e:{var a=e,r=a._payload,i=a._init;try{return nl(i(r),t,n)}catch{}}}return""}function tf(e){switch(e._debugOwner&&e._debugOwner.type,e._debugSource,e.tag){case G:return Va(e.type);case Ke:return Va("Lazy");case ae:return Va("Suspense");case Je:return Va("SuspenseList");case ne:case Ie:case ee:return tl(e.type);case oe:return tl(e.type.render);case $:return Wi(e.type);default:return""}}function ii(e){try{var t="",n=e;do t+=tf(n),n=n.return;while(n);return t}catch(a){return`
-Error generating stack: `+a.message+`
-`+a.stack}}function Yu(e,t,n){var a=e.displayName;if(a)return a;var r=t.displayName||t.name||"";return r!==""?n+"("+r+")":n}function al(e){return e.displayName||"Context"}function ut(e){if(e==null)return null;if(typeof e.tag=="number"&&d("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."),typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Fa:return"Fragment";case ea:return"Portal";case ti:return"Profiler";case ei:return"StrictMode";case V:return"Suspense";case Q:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case p:var t=e;return al(t)+".Consumer";case ni:var n=e;return al(n._context)+".Provider";case O:return Yu(e,e.render,"ForwardRef");case $e:var a=e.displayName||null;return a!==null?a:ut(e.type)||"Memo";case _e:{var r=e,i=r._payload,o=r._init;try{return ut(o(i))}catch{return null}}}return null}function nf(e,t,n){var a=t.displayName||t.name||"";return e.displayName||(a!==""?n+"("+a+")":n)}function tr(e){return e.displayName||"Context"}function we(e){var t=e.tag,n=e.type;switch(t){case nt:return"Cache";case Mt:var a=n;return tr(a)+".Consumer";case he:var r=n;return tr(r._context)+".Provider";case Ct:return"DehydratedFragment";case oe:return nf(n,n.render,"ForwardRef");case Oe:return"Fragment";case G:return n;case Te:return"Portal";case H:return"Root";case Le:return"Text";case Ke:return ut(n);case At:return n===ei?"StrictMode":"Mode";case Re:return"Offscreen";case et:return"Profiler";case ge:return"Scope";case ae:return"Suspense";case Je:return"SuspenseList";case Lt:return"TracingMarker";case $:case ne:case He:case Ie:case tt:case ee:if(typeof n=="function")return n.displayName||n.name||null;if(typeof n=="string")return n;break}return null}var rl=se.ReactDebugCurrentFrame,Un=null,oi=!1;function Dr(){{if(Un===null)return null;var e=Un._debugOwner;if(e!==null&&typeof e<"u")return we(e)}return null}function af(){return Un===null?"":ii(Un)}function cn(){rl.getCurrentStack=null,Un=null,oi=!1}function Ft(e){rl.getCurrentStack=e===null?null:af,Un=e,oi=!1}function qu(){return Un}function ua(e){oi=e}function An(e){return""+e}function Ta(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return fe(e),e;default:return""}}var rf={button:!0,checkbox:!0,image:!0,hidden:!0,radio:!0,reset:!0,submit:!0};function il(e,t){rf[t.type]||t.onChange||t.onInput||t.readOnly||t.disabled||t.value==null||d("You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`."),t.onChange||t.readOnly||t.disabled||t.checked==null||d("You provided a `checked` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultChecked`. Otherwise, set either `onChange` or `readOnly`.")}function Gu(e){var t=e.type,n=e.nodeName;return n&&n.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function ol(e){return e._valueTracker}function Qi(e){e._valueTracker=null}function of(e){var t="";return e&&(Gu(e)?t=e.checked?"true":"false":t=e.value),t}function _r(e){var t=Gu(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t);fe(e[t]);var a=""+e[t];if(!(e.hasOwnProperty(t)||typeof n>"u"||typeof n.get!="function"||typeof n.set!="function")){var r=n.get,i=n.set;Object.defineProperty(e,t,{configurable:!0,get:function(){return r.call(this)},set:function(l){fe(l),a=""+l,i.call(this,l)}}),Object.defineProperty(e,t,{enumerable:n.enumerable});var o={getValue:function(){return a},setValue:function(l){fe(l),a=""+l},stopTracking:function(){Qi(e),delete e[t]}};return o}}function li(e){ol(e)||(e._valueTracker=_r(e))}function ll(e){if(!e)return!1;var t=ol(e);if(!t)return!0;var n=t.getValue(),a=of(e);return a!==n?(t.setValue(a),!0):!1}function nr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var Xi=!1,Ii=!1,Ki=!1,Wu=!1;function Qu(e){var t=e.type==="checkbox"||e.type==="radio";return t?e.checked!=null:e.value!=null}function ul(e,t){var n=e,a=t.checked,r=Ne({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:a??n._wrapperState.initialChecked});return r}function Xu(e,t){il("input",t),t.checked!==void 0&&t.defaultChecked!==void 0&&!Ii&&(d("%s contains an input of type %s with both checked and defaultChecked props. Input elements must be either controlled or uncontrolled (specify either the checked prop, or the defaultChecked prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://reactjs.org/link/controlled-components",Dr()||"A component",t.type),Ii=!0),t.value!==void 0&&t.defaultValue!==void 0&&!Xi&&(d("%s contains an input of type %s with both value and defaultValue props. Input elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://reactjs.org/link/controlled-components",Dr()||"A component",t.type),Xi=!0);var n=e,a=t.defaultValue==null?"":t.defaultValue;n._wrapperState={initialChecked:t.checked!=null?t.checked:t.defaultChecked,initialValue:Ta(t.value!=null?t.value:a),controlled:Qu(t)}}function s(e,t){var n=e,a=t.checked;a!=null&&Sa(n,"checked",a,!1)}function v(e,t){var n=e;{var a=Qu(t);!n._wrapperState.controlled&&a&&!Wu&&(d("A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components"),Wu=!0),n._wrapperState.controlled&&!a&&!Ki&&(d("A component is changing a controlled input to be uncontrolled. This is likely caused by the value changing from a defined to undefined, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components"),Ki=!0)}s(e,t);var r=Ta(t.value),i=t.type;if(r!=null)i==="number"?(r===0&&n.value===""||n.value!=r)&&(n.value=An(r)):n.value!==An(r)&&(n.value=An(r));else if(i==="submit"||i==="reset"){n.removeAttribute("value");return}t.hasOwnProperty("value")?ue(n,t.type,r):t.hasOwnProperty("defaultValue")&&ue(n,t.type,Ta(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(n.defaultChecked=!!t.defaultChecked)}function C(e,t,n){var a=e;if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type,i=r==="submit"||r==="reset";if(i&&(t.value===void 0||t.value===null))return;var o=An(a._wrapperState.initialValue);n||o!==a.value&&(a.value=o),a.defaultValue=o}var l=a.name;l!==""&&(a.name=""),a.defaultChecked=!a.defaultChecked,a.defaultChecked=!!a._wrapperState.initialChecked,l!==""&&(a.name=l)}function T(e,t){var n=e;v(n,t),U(n,t)}function U(e,t){var n=t.name;if(t.type==="radio"&&n!=null){for(var a=e;a.parentNode;)a=a.parentNode;L(n,"name");for(var r=a.querySelectorAll("input[name="+JSON.stringify(""+n)+'][type="radio"]'),i=0;i.")))}):t.dangerouslySetInnerHTML!=null&&(Ge||(Ge=!0,d("Pass a `value` prop if you set dangerouslyInnerHTML so React knows which value should be selected.")))),t.selected!=null&&!I&&(d("Use the `defaultValue` or `value` props on instead of setting `selected` on ."),I=!0)}function Dt(e,t){t.value!=null&&e.setAttribute("value",An(Ta(t.value)))}var _t=Array.isArray;function Fe(e){return _t(e)}var Vt;Vt=!1;function Xt(){var e=Dr();return e?`
-
-Check the render method of \``+e+"`.":""}var ui=["value","defaultValue"];function sl(e){{il("select",e);for(var t=0;t must be an array if `multiple` is true.%s",n,Xt()):!e.multiple&&a&&d("The `%s` prop supplied to must be a scalar value if `multiple` is false.%s",n,Xt())}}}}function ar(e,t,n,a){var r=e.options;if(t){for(var i=n,o={},l=0;l.");var a=Ne({},t,{value:void 0,defaultValue:void 0,children:An(n._wrapperState.initialValue)});return a}function xh(e,t){var n=e;il("textarea",t),t.value!==void 0&&t.defaultValue!==void 0&&!Rh&&(d("%s contains a textarea with both value and defaultValue props. Textarea elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled textarea and remove one of these props. More info: https://reactjs.org/link/controlled-components",Dr()||"A component"),Rh=!0);var a=t.value;if(a==null){var r=t.children,i=t.defaultValue;if(r!=null){d("Use the `defaultValue` or `value` props instead of setting children on