feat: Video Trimmer and more
4
.gitignore
vendored
@ -18,3 +18,7 @@ static/drf-yasg
|
||||
cms/local_settings.py
|
||||
deploy/docker/local_settings.py
|
||||
yt.readme.md
|
||||
/frontend-tools/video-editor/node_modules
|
||||
/frontend-tools/video-editor/client/node_modules
|
||||
/static_collected
|
||||
/frontend-tools/video-editor-v1
|
||||
|
||||
1
.prettierignore
Normal file
@ -0,0 +1 @@
|
||||
*
|
||||
@ -496,6 +496,7 @@ USE_RBAC = False
|
||||
USE_IDENTITY_PROVIDERS = False
|
||||
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
|
||||
|
||||
USE_ROUNDED_CORNERS = True
|
||||
|
||||
try:
|
||||
# keep a local_settings.py file for local overrides
|
||||
|
||||
26
deploy/scripts/build_and_deploy.sh
Normal file
@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit on any error
|
||||
set -e
|
||||
|
||||
echo "Starting build process..."
|
||||
|
||||
# Build video editor package
|
||||
echo "Building video editor package..."
|
||||
cd frontend-tools/video-editor
|
||||
yarn build:django
|
||||
cd ../../
|
||||
|
||||
# Run npm build in the frontend container
|
||||
echo "Building frontend assets..."
|
||||
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..."
|
||||
cp -r frontend/dist/static/* static/
|
||||
|
||||
# Restart the web service
|
||||
echo "Restarting web service..."
|
||||
docker compose -f docker-compose/docker-compose-dev-updated.yaml restart web
|
||||
|
||||
echo "Build and deployment completed successfully!"
|
||||
@ -1,15 +1,16 @@
|
||||
name: mediacms-dev
|
||||
services:
|
||||
migrations:
|
||||
platform: linux/amd64
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- DEVELOPMENT_MODE=True
|
||||
image: mediacms/mediacms:latest
|
||||
volumes:
|
||||
- ./:/home/mediacms.io/mediacms/
|
||||
command: "./deploy/docker/prestart.sh"
|
||||
- ../:/home/mediacms.io/mediacms/
|
||||
command: "/home/mediacms.io/mediacms/deploy/docker/prestart.sh"
|
||||
environment:
|
||||
DEVELOPMENT_MODE: True
|
||||
ENABLE_UWSGI: 'no'
|
||||
@ -95,13 +96,13 @@ services:
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./:/home/mediacms.io/mediacms/
|
||||
- ../:/home/mediacms.io/mediacms/
|
||||
depends_on:
|
||||
- migrations
|
||||
db:
|
||||
image: postgres:17.2-alpine
|
||||
volumes:
|
||||
- ./postgres_data:/var/lib/postgresql/data/
|
||||
- ../postgres_data:/var/lib/postgresql/data/
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: mediacms
|
||||
@ -127,7 +128,7 @@ services:
|
||||
deploy:
|
||||
replicas: 1
|
||||
volumes:
|
||||
- ./:/home/mediacms.io/mediacms/
|
||||
- ../:/home/mediacms.io/mediacms/
|
||||
environment:
|
||||
ENABLE_UWSGI: 'no'
|
||||
ENABLE_NGINX: 'no'
|
||||
|
||||
@ -809,14 +809,8 @@ This will disable the transcoding process and only the original file will be sho
|
||||
|
||||
## 19. Rounded corners on videos
|
||||
|
||||
By default the video player and media items are now having rounded corners, on larger screens (not in mobile). If you don't like this change, remove the `border-radius` added on the following files:
|
||||
By default the video player and media items are now having rounded corners, on larger screens (not in mobile). If you don't like this change, set `USE_ROUNDED_CORNERS = False` in `local_settings.py`.
|
||||
|
||||
```
|
||||
frontend/src/static/css/_extra.css
|
||||
frontend/src/static/js/components/list-item/Item.scss
|
||||
frontend/src/static/js/components/media-page/MediaPage.scss
|
||||
```
|
||||
you now have to re-run the frontend build in order to see the changes (check docs/dev_exp.md)
|
||||
|
||||
|
||||
## 20. Translations
|
||||
|
||||
@ -13,6 +13,7 @@ from .models import (
|
||||
Encoding,
|
||||
Language,
|
||||
Media,
|
||||
VideoTrimRequest,
|
||||
Subtitle,
|
||||
Tag,
|
||||
)
|
||||
@ -198,6 +199,9 @@ class LanguageAdmin(admin.ModelAdmin):
|
||||
class SubtitleAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
class VideoTrimRequestAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class EncodingAdmin(admin.ModelAdmin):
|
||||
list_display = ["get_title", "chunk", "profile", "progress", "status", "has_file"]
|
||||
@ -222,5 +226,6 @@ admin.site.register(Category, CategoryAdmin)
|
||||
admin.site.register(Tag, TagAdmin)
|
||||
admin.site.register(Subtitle, SubtitleAdmin)
|
||||
admin.site.register(Language, LanguageAdmin)
|
||||
admin.site.register(VideoTrimRequest, VideoTrimRequestAdmin)
|
||||
|
||||
Media._meta.app_config.verbose_name = "Media"
|
||||
|
||||
@ -35,6 +35,9 @@ def stuff(request):
|
||||
ret["TRANSLATION"] = get_translation(request.LANGUAGE_CODE)
|
||||
ret["REPLACEMENTS"] = get_translation_strings(request.LANGUAGE_CODE)
|
||||
ret["USE_SAML"] = settings.USE_SAML
|
||||
ret["USE_RBAC"] = settings.USE_RBAC
|
||||
ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS
|
||||
|
||||
if request.user.is_superuser:
|
||||
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL
|
||||
|
||||
|
||||
189
files/forms.py
@ -1,49 +1,141 @@
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Field, Layout, Submit
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
|
||||
from .methods import get_next_state, is_mediacms_editor
|
||||
from .models import Category, Media, Subtitle
|
||||
from .models import Category, Media, MEDIA_STATES, Subtitle
|
||||
|
||||
|
||||
class CustomField(Field):
|
||||
template = 'cms/crispy_custom_field.html'
|
||||
|
||||
|
||||
class MultipleSelect(forms.CheckboxSelectMultiple):
|
||||
input_type = "checkbox"
|
||||
|
||||
|
||||
class MediaForm(forms.ModelForm):
|
||||
new_tags = forms.CharField(label="Tags", help_text="a comma separated list of new tags.", required=False)
|
||||
class MediaMetadataForm(forms.ModelForm):
|
||||
new_tags = forms.CharField(label="Tags", help_text="a comma separated list of tags.", required=False)
|
||||
|
||||
class Meta:
|
||||
model = Media
|
||||
fields = (
|
||||
"title",
|
||||
"category",
|
||||
"new_tags",
|
||||
"add_date",
|
||||
"uploaded_poster",
|
||||
"description",
|
||||
"state",
|
||||
"enable_comments",
|
||||
"featured",
|
||||
"thumbnail_time",
|
||||
"reported_times",
|
||||
"is_reviewed",
|
||||
"allow_download",
|
||||
)
|
||||
|
||||
widgets = {
|
||||
"tags": MultipleSelect(),
|
||||
"new_tags": MultipleSelect(),
|
||||
"description": forms.Textarea(attrs={'rows': 4}),
|
||||
"add_date": forms.DateInput(attrs={'type': 'date'}),
|
||||
"thumbnail_time": forms.NumberInput(attrs={'min': 0, 'step': 0.1}),
|
||||
}
|
||||
labels = {
|
||||
"uploaded_poster": "Poster Image",
|
||||
"thumbnail_time": "Thumbnail Time (seconds)",
|
||||
}
|
||||
help_texts = {
|
||||
"title": "",
|
||||
"thumbnail_time": "Select the time in seconds for the video thumbnail",
|
||||
"uploaded_poster": "Maximum file size: 5MB",
|
||||
}
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.user = user
|
||||
super(MediaForm, self).__init__(*args, **kwargs)
|
||||
super(MediaMetadataForm, self).__init__(*args, **kwargs)
|
||||
if self.instance.media_type != "video":
|
||||
self.fields.pop("thumbnail_time")
|
||||
if self.instance.media_type == "image":
|
||||
self.fields.pop("uploaded_poster")
|
||||
|
||||
self.fields["new_tags"].initial = ", ".join([tag.title for tag in self.instance.tags.all()])
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = True
|
||||
self.helper.form_class = 'post-form'
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_enctype = "multipart/form-data"
|
||||
self.helper.form_show_errors = False
|
||||
self.helper.layout = Layout(
|
||||
CustomField('title'),
|
||||
CustomField('new_tags'),
|
||||
CustomField('add_date'),
|
||||
CustomField('description'),
|
||||
CustomField('uploaded_poster'),
|
||||
CustomField('enable_comments'),
|
||||
)
|
||||
|
||||
if self.instance.media_type == "video":
|
||||
self.helper.layout.append(CustomField('thumbnail_time'))
|
||||
|
||||
self.helper.layout.append(FormActions(Submit('submit', 'Update Media', css_class='primaryAction')))
|
||||
|
||||
def clean_uploaded_poster(self):
|
||||
image = self.cleaned_data.get("uploaded_poster", False)
|
||||
if image:
|
||||
if image.size > 5 * 1024 * 1024:
|
||||
raise forms.ValidationError("Image file too large ( > 5mb )")
|
||||
return image
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
data = self.cleaned_data # noqa
|
||||
|
||||
media = super(MediaMetadataForm, self).save(*args, **kwargs)
|
||||
return media
|
||||
|
||||
|
||||
class MediaPublishForm(forms.ModelForm):
|
||||
confirm_state = forms.BooleanField(
|
||||
required=False,
|
||||
initial=False,
|
||||
label="Acknowledge sharing status",
|
||||
help_text=""
|
||||
)
|
||||
|
||||
create_segments = forms.BooleanField(
|
||||
required=False,
|
||||
initial=False,
|
||||
label="Create segments from video chapters",
|
||||
help_text="If checked, separate media files will be created for each chapter"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Media
|
||||
fields = (
|
||||
"category",
|
||||
"state",
|
||||
"featured",
|
||||
"reported_times",
|
||||
"is_reviewed",
|
||||
"allow_download",
|
||||
)
|
||||
|
||||
widgets = {
|
||||
"category": MultipleSelect(),
|
||||
}
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.user = user
|
||||
super(MediaPublishForm, self).__init__(*args, **kwargs)
|
||||
if not is_mediacms_editor(user):
|
||||
self.fields.pop("featured")
|
||||
self.fields.pop("reported_times")
|
||||
self.fields.pop("is_reviewed")
|
||||
# if settings.PORTAL_WORKFLOW == 'private':
|
||||
# self.fields.pop("state")
|
||||
for field in ["featured", "reported_times", "is_reviewed"]:
|
||||
self.fields[field].disabled = True
|
||||
self.fields[field].widget.attrs['class'] = 'read-only-field'
|
||||
self.fields[field].widget.attrs['title'] = "This field can only be modified by MediaCMS admins or editors"
|
||||
|
||||
if settings.PORTAL_WORKFLOW not in ["public"]:
|
||||
valid_states = ["unlisted", "private"]
|
||||
if self.instance.state and self.instance.state not in valid_states:
|
||||
valid_states.append(self.instance.state)
|
||||
self.fields["state"].choices = [(state, dict(MEDIA_STATES).get(state, state)) for state in valid_states]
|
||||
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
|
||||
if is_mediacms_editor(user):
|
||||
@ -61,14 +153,62 @@ class MediaForm(forms.ModelForm):
|
||||
|
||||
self.fields['category'].queryset = Category.objects.filter(id__in=combined_category_ids).order_by('title')
|
||||
|
||||
self.fields["new_tags"].initial = ", ".join([tag.title for tag in self.instance.tags.all()])
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = True
|
||||
self.helper.form_class = 'post-form'
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_enctype = "multipart/form-data"
|
||||
self.helper.form_show_errors = False
|
||||
self.helper.layout = Layout(
|
||||
CustomField('category'),
|
||||
CustomField('state'),
|
||||
CustomField('featured'),
|
||||
CustomField('reported_times'),
|
||||
CustomField('is_reviewed'),
|
||||
CustomField('allow_download'),
|
||||
)
|
||||
|
||||
def clean_uploaded_poster(self):
|
||||
image = self.cleaned_data.get("uploaded_poster", False)
|
||||
if image:
|
||||
if image.size > 5 * 1024 * 1024:
|
||||
raise forms.ValidationError("Image file too large ( > 5mb )")
|
||||
return image
|
||||
# Only add create_segments field if the media has chapters
|
||||
if self.instance.media_type == 'video' and self.instance.chapters.exists():
|
||||
self.fields['create_segments'] = forms.BooleanField(
|
||||
required=False,
|
||||
initial=False,
|
||||
label="Create segments from video chapters",
|
||||
help_text="If checked, separate media files will be created for each chapter"
|
||||
)
|
||||
self.helper.layout.append(CustomField('create_segments'))
|
||||
|
||||
self.helper.layout.append(FormActions(Submit('submit', 'Publish Media', css_class='primaryAction')))
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
state = cleaned_data.get("state")
|
||||
categories = cleaned_data.get("category")
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
|
||||
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
|
||||
|
||||
if rbac_categories and state in ['private', 'unlisted']:
|
||||
# Make the confirm_state field visible and add it to the layout
|
||||
self.fields['confirm_state'].widget = forms.CheckboxInput()
|
||||
|
||||
state_index = None
|
||||
for i, layout_item in enumerate(self.helper.layout):
|
||||
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
|
||||
state_index = i
|
||||
break
|
||||
|
||||
if state_index:
|
||||
layout_items = list(self.helper.layout)
|
||||
layout_items.insert(state_index + 1, CustomField('confirm_state'))
|
||||
self.helper.layout = Layout(*layout_items)
|
||||
|
||||
|
||||
if not cleaned_data.get('confirm_state'):
|
||||
error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to the following categories: {', '.join(rbac_categories)}"
|
||||
self.add_error('confirm_state', error_message)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
data = self.cleaned_data
|
||||
@ -76,7 +216,8 @@ class MediaForm(forms.ModelForm):
|
||||
if state != self.initial["state"]:
|
||||
self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state)
|
||||
|
||||
media = super(MediaForm, self).save(*args, **kwargs)
|
||||
media = super(MediaPublishForm, self).save(*args, **kwargs)
|
||||
|
||||
return media
|
||||
|
||||
|
||||
|
||||
191
files/helpers.py
@ -3,6 +3,7 @@
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
@ -15,6 +16,9 @@ from django.conf import settings
|
||||
|
||||
CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
CRF_ENCODING_NUM_SECONDS = 2 # 0 * 60 # videos with greater duration will get
|
||||
# CRF encoding and not two-pass
|
||||
# Encoding individual chunks may yield quality variations if you use a
|
||||
@ -787,6 +791,193 @@ def clean_query(query):
|
||||
return query.lower()
|
||||
|
||||
|
||||
def timestamp_to_seconds(timestamp):
|
||||
"""Convert a timestamp in format HH:MM:SS.mmm to seconds
|
||||
|
||||
Args:
|
||||
timestamp (str): Timestamp in format HH:MM:SS.mmm
|
||||
|
||||
Returns:
|
||||
float: Timestamp in seconds
|
||||
"""
|
||||
h, m, s = timestamp.split(':')
|
||||
s, ms = s.split('.')
|
||||
return int(h) * 3600 + int(m) * 60 + int(s) + float('0.' + ms)
|
||||
|
||||
|
||||
def seconds_to_timestamp(seconds):
|
||||
"""Convert seconds to timestamp in format HH:MM:SS.mmm
|
||||
|
||||
Args:
|
||||
seconds (float): Time in seconds
|
||||
|
||||
Returns:
|
||||
str: Timestamp in format HH:MM:SS.mmm
|
||||
"""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
seconds_remainder = seconds % 60
|
||||
seconds_int = int(seconds_remainder)
|
||||
milliseconds = int((seconds_remainder - seconds_int) * 1000)
|
||||
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds_int:02d}.{milliseconds:03d}"
|
||||
|
||||
def get_trim_timestamps(media_file_path, timestamps_list, run_ffprobe=False):
|
||||
"""Process a list of timestamps to align start times with I-frames for better video trimming
|
||||
|
||||
Args:
|
||||
media_file_path (str): Path to the media file
|
||||
timestamps_list (list): List of dictionaries with startTime and endTime
|
||||
|
||||
Returns:
|
||||
list: Processed timestamps with adjusted startTime values
|
||||
"""
|
||||
if not isinstance(timestamps_list, list):
|
||||
return []
|
||||
|
||||
timestamps_results = []
|
||||
timestamps_to_process = []
|
||||
|
||||
for item in timestamps_list:
|
||||
if isinstance(item, dict) and 'startTime' in item and 'endTime' in item:
|
||||
timestamps_to_process.append(item)
|
||||
|
||||
if not timestamps_to_process:
|
||||
return []
|
||||
|
||||
# just a single timestamp with no startTime, no need to process
|
||||
if len(timestamps_to_process) == 1 and timestamps_to_process[0]['startTime'] == "00:00:00.000":
|
||||
return timestamps_list
|
||||
|
||||
# Process each timestamp
|
||||
for item in timestamps_to_process:
|
||||
startTime = item['startTime']
|
||||
endTime = item['endTime']
|
||||
|
||||
# with ffmpeg -ss -i that is getting run, there is no need to call ffprobe to find the I-frame,
|
||||
# as ffmpeg will do that. Keeping this for now in case it is needed
|
||||
|
||||
i_frames = []
|
||||
if run_ffprobe:
|
||||
SEC_TO_SUBTRACT = 10
|
||||
start_seconds = timestamp_to_seconds(startTime)
|
||||
search_start = max(0, start_seconds - SEC_TO_SUBTRACT)
|
||||
|
||||
# Create ffprobe command to find nearest I-frame
|
||||
cmd = [
|
||||
settings.FFPROBE_COMMAND,
|
||||
"-v", "error",
|
||||
"-select_streams", "v:0",
|
||||
"-show_entries", "frame=pts_time,pict_type",
|
||||
"-of", "csv=p=0",
|
||||
"-read_intervals", f"{search_start}%{startTime}",
|
||||
media_file_path
|
||||
]
|
||||
cmd = [str(s) for s in cmd]
|
||||
logger.info(f"trim cmd: {cmd}")
|
||||
|
||||
stdout = run_command(cmd).get("out")
|
||||
|
||||
if stdout:
|
||||
for line in stdout.strip().split('\n'):
|
||||
if line and line.endswith(',I'):
|
||||
i_frames.append(line.replace(',I', ''))
|
||||
|
||||
if i_frames:
|
||||
adjusted_startTime = seconds_to_timestamp(float(i_frames[-1]))
|
||||
|
||||
if not i_frames:
|
||||
adjusted_startTime = startTime
|
||||
|
||||
timestamps_results.append({
|
||||
'startTime': adjusted_startTime,
|
||||
'endTime': endTime
|
||||
})
|
||||
|
||||
return timestamps_results
|
||||
|
||||
|
||||
def trim_video_method(media_file_path, timestamps_list):
|
||||
"""Trim a video file based on a list of timestamps
|
||||
|
||||
Args:
|
||||
media_file_path (str): Path to the media file
|
||||
timestamps_list (list): List of dictionaries with startTime and endTime
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
if not isinstance(timestamps_list, list) or not timestamps_list:
|
||||
return False
|
||||
|
||||
if not os.path.exists(media_file_path):
|
||||
return False
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
|
||||
output_file = os.path.join(temp_dir, "output.mp4")
|
||||
|
||||
segment_files = []
|
||||
for i, item in enumerate(timestamps_list):
|
||||
start_time = timestamp_to_seconds(item['startTime'])
|
||||
end_time = timestamp_to_seconds(item['endTime'])
|
||||
duration = end_time - start_time
|
||||
|
||||
# For single timestamp, we can use the output file directly
|
||||
# For multiple timestamps, we need to create segment files
|
||||
segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}.mp4")
|
||||
|
||||
cmd = [
|
||||
settings.FFMPEG_COMMAND,
|
||||
"-y",
|
||||
"-ss", str(item['startTime']),
|
||||
"-i", media_file_path,
|
||||
"-t", str(duration),
|
||||
"-c", "copy",
|
||||
"-avoid_negative_ts", "1",
|
||||
segment_file
|
||||
]
|
||||
|
||||
result = run_command(cmd)
|
||||
|
||||
if os.path.exists(segment_file) and os.path.getsize(segment_file) > 0:
|
||||
if len(timestamps_list) > 1:
|
||||
segment_files.append(segment_file)
|
||||
else:
|
||||
return False
|
||||
|
||||
if len(timestamps_list) > 1:
|
||||
if not segment_files:
|
||||
return False
|
||||
|
||||
concat_list_path = os.path.join(temp_dir, "concat_list.txt")
|
||||
with open(concat_list_path, "w") as f:
|
||||
for segment in segment_files:
|
||||
f.write(f"file '{segment}'\n")
|
||||
concat_cmd = [
|
||||
settings.FFMPEG_COMMAND,
|
||||
"-y",
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-i", concat_list_path,
|
||||
"-c", "copy",
|
||||
output_file
|
||||
]
|
||||
|
||||
concat_result = run_command(concat_cmd)
|
||||
|
||||
if not os.path.exists(output_file) or os.path.getsize(output_file) == 0:
|
||||
return False
|
||||
|
||||
# Replace the original file with the trimmed version
|
||||
try:
|
||||
rm_file(media_file_path)
|
||||
shutil.copy2(output_file, media_file_path)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.info(f"Failed to replace original file: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def get_alphanumeric_only(string):
|
||||
"""Returns a query that contains only alphanumeric characters
|
||||
This include characters other than the English alphabet too
|
||||
|
||||
112
files/methods.py
@ -2,19 +2,25 @@
|
||||
# related content
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files import File
|
||||
from django.core.mail import EmailMessage
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from cms import celery_app
|
||||
|
||||
from . import models
|
||||
from . import helpers, models
|
||||
from .models import Encoding
|
||||
from .helpers import mask_ip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -262,7 +268,7 @@ def show_related_media_content(media, request, limit):
|
||||
"user_featured",
|
||||
"-user_featured",
|
||||
]
|
||||
# TODO: MAke this mess more readable, and add TAGS support - aka related
|
||||
# TODO: Make this mess more readable, and add TAGS support - aka related
|
||||
# tags rather than random media
|
||||
if len(m) < limit:
|
||||
category = media.category.first()
|
||||
@ -398,6 +404,97 @@ def clean_comment(raw_comment):
|
||||
return cleaned_comment
|
||||
|
||||
|
||||
def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"):
|
||||
"""Create a copy of a media object
|
||||
|
||||
Args:
|
||||
original_media: Original Media object to copy
|
||||
copy_encodings: Whether to copy the encodings too
|
||||
|
||||
Returns:
|
||||
New Media object
|
||||
"""
|
||||
|
||||
with open(original_media.media_file.path, "rb") as f:
|
||||
myfile = File(f)
|
||||
new_media = models.Media.objects.create(
|
||||
media_file=myfile,
|
||||
title=f"{original_media.title} {title_suffix}",
|
||||
description=original_media.description,
|
||||
user=original_media.user,
|
||||
media_type="video",
|
||||
enable_comments=original_media.enable_comments,
|
||||
allow_download=original_media.allow_download,
|
||||
state=original_media.state,
|
||||
is_reviewed=original_media.is_reviewed,
|
||||
add_date=timezone.now()
|
||||
)
|
||||
|
||||
if copy_encodings:
|
||||
for encoding in original_media.encodings.filter(status="success", chunk=False):
|
||||
if encoding.media_file:
|
||||
with open(encoding.media_file.path, "rb") as f:
|
||||
myfile = File(f)
|
||||
new_encoding = Encoding.objects.create(
|
||||
media_file=myfile,
|
||||
media=new_media,
|
||||
profile=encoding.profile,
|
||||
status="success",
|
||||
progress=100,
|
||||
chunk=False,
|
||||
logs=f"Copied from encoding {encoding.id}"
|
||||
)
|
||||
new_encoding.save()
|
||||
|
||||
# Copy categories and tags
|
||||
for category in original_media.category.all():
|
||||
new_media.category.add(category)
|
||||
|
||||
for tag in original_media.tags.all():
|
||||
new_media.tags.add(tag)
|
||||
|
||||
# Copy thumbnails if they exist
|
||||
if original_media.thumbnail:
|
||||
with open(original_media.thumbnail.path, 'rb') as f:
|
||||
thumbnail_name = helpers.get_file_name(original_media.thumbnail.path)
|
||||
new_media.thumbnail.save(thumbnail_name, File(f))
|
||||
|
||||
if original_media.poster:
|
||||
with open(original_media.poster.path, 'rb') as f:
|
||||
poster_name = helpers.get_file_name(original_media.poster.path)
|
||||
new_media.poster.save(poster_name, File(f))
|
||||
|
||||
return new_media
|
||||
|
||||
|
||||
def create_video_trim_request(media, data):
|
||||
"""Create a video trim request for a media
|
||||
|
||||
Args:
|
||||
media: Media object
|
||||
data: Dictionary with trim request data
|
||||
|
||||
Returns:
|
||||
VideoTrimRequest object
|
||||
"""
|
||||
|
||||
video_action = "replace"
|
||||
if data.get('saveIndividualSegments'):
|
||||
video_action = "create_segments"
|
||||
elif data.get('saveAsCopy'):
|
||||
video_action = "save_new"
|
||||
|
||||
video_trim_request = models.VideoTrimRequest.objects.create(
|
||||
media=media,
|
||||
status="initial",
|
||||
video_action=video_action,
|
||||
media_trim_style='no_encoding',
|
||||
timestamps=data.get('segments', {})
|
||||
)
|
||||
|
||||
return video_trim_request
|
||||
|
||||
|
||||
def list_tasks():
|
||||
"""Lists celery tasks
|
||||
To be used in an admin dashboard
|
||||
@ -448,3 +545,14 @@ def list_tasks():
|
||||
ret["task_ids"] = task_ids
|
||||
ret["media_profile_pairs"] = media_profile_pairs
|
||||
return ret
|
||||
|
||||
|
||||
def handle_video_chapters(media, chapters):
|
||||
video_chapter = models.VideoChapterData.objects.filter(media=media).first()
|
||||
if video_chapter:
|
||||
video_chapter.data = chapters
|
||||
video_chapter.save()
|
||||
else:
|
||||
video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters)
|
||||
|
||||
return media.chapter_data
|
||||
|
||||
24
files/migrations/0007_alter_media_state_videochapterdata.py
Normal file
@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.6 on 2025-04-15 07:26
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0006_alter_category_title'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VideoChapterData',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('data', models.JSONField(help_text='Chapter data')),
|
||||
('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapters', to='files.media')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('media',)},
|
||||
},
|
||||
),
|
||||
]
|
||||
31
files/migrations/0008_alter_media_state_videotrimrequest.py
Normal file
@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.1.6 on 2025-05-02 14:23
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('files', '0007_alter_media_state_videochapterdata'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='media',
|
||||
name='state',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public'), ('unlisted', 'Unlisted')], db_index=True, default='public', help_text='state of Media', max_length=20),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VideoTrimRequest',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('initial', 'Initial'), ('running', 'Running'), ('success', 'Success'), ('fail', 'Fail')], default='initial', max_length=20)),
|
||||
('add_date', models.DateTimeField(auto_now_add=True)),
|
||||
('video_action', models.CharField(choices=[('replace', 'Replace Original'), ('save_new', 'Save as New'), ('create_segments', 'Create Segments')], max_length=20)),
|
||||
('media_trim_style', models.CharField(choices=[('no_encoding', 'No Encoding'), ('precise', 'Precise')], default='no_encoding', max_length=20)),
|
||||
('timestamps', models.JSONField(help_text='Timestamps for trimming')),
|
||||
('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trim_requests', to='files.media')),
|
||||
],
|
||||
),
|
||||
]
|
||||
122
files/models.py
@ -641,7 +641,7 @@ class Media(models.Model):
|
||||
|
||||
self.save(update_fields=["encoding_status", "listable"])
|
||||
|
||||
if encoding and encoding.status == "success" and encoding.profile.codec == "h264" and action == "add":
|
||||
if encoding and encoding.status == "success" and encoding.profile.codec == "h264" and action == "add" and not encoding.chunk:
|
||||
from . import tasks
|
||||
|
||||
tasks.create_hls(self.friendly_token)
|
||||
@ -667,6 +667,29 @@ class Media(models.Model):
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def trim_video_url(self):
|
||||
if self.media_type not in ["video"]:
|
||||
return None
|
||||
|
||||
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()
|
||||
if ret:
|
||||
return helpers.url_from_path(ret.media_file.path)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@property
|
||||
def trim_video_path(self):
|
||||
if self.media_type not in ["video"]:
|
||||
return None
|
||||
|
||||
ret = self.encodings.filter(status="success", profile__extension='mp4').order_by("-profile__resolution").first()
|
||||
if ret:
|
||||
return ret.media_file.path
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def encodings_info(self, full=False):
|
||||
"""Property used on serializers"""
|
||||
@ -948,6 +971,19 @@ class Media(models.Model):
|
||||
)
|
||||
return ret
|
||||
|
||||
@property
|
||||
def video_chapters_folder(self):
|
||||
custom_folder = f"{settings.THUMBNAIL_UPLOAD_DIR}{self.user.username}/{self.friendly_token}_chapters"
|
||||
return os.path.join(settings.MEDIA_ROOT, custom_folder)
|
||||
|
||||
@property
|
||||
def chapter_data(self):
|
||||
data = []
|
||||
chapter_data = self.chapters.first()
|
||||
if chapter_data:
|
||||
return chapter_data.chapter_data
|
||||
return data
|
||||
|
||||
|
||||
class License(models.Model):
|
||||
"""A Base license model to be used in Media"""
|
||||
@ -1440,6 +1476,82 @@ class Comment(MPTTModel):
|
||||
return self.get_absolute_url()
|
||||
|
||||
|
||||
class VideoChapterData(models.Model):
|
||||
data = models.JSONField(null=False, blank=False, help_text="Chapter data")
|
||||
media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='chapters')
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
class VideoTrimRequest(models.Model):
|
||||
"""Model to handle video trimming requests"""
|
||||
|
||||
VIDEO_TRIM_STATUS = (
|
||||
("initial", "Initial"),
|
||||
("running", "Running"),
|
||||
("success", "Success"),
|
||||
("fail", "Fail"),
|
||||
)
|
||||
|
||||
VIDEO_ACTION_CHOICES = (
|
||||
("replace", "Replace Original"),
|
||||
("save_new", "Save as New"),
|
||||
("create_segments", "Create Segments"),
|
||||
)
|
||||
|
||||
TRIM_STYLE_CHOICES = (
|
||||
("no_encoding", "No Encoding"),
|
||||
("precise", "Precise"),
|
||||
)
|
||||
|
||||
media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='trim_requests')
|
||||
status = models.CharField(max_length=20, choices=VIDEO_TRIM_STATUS, default="initial")
|
||||
add_date = models.DateTimeField(auto_now_add=True)
|
||||
video_action = models.CharField(max_length=20, choices=VIDEO_ACTION_CHOICES)
|
||||
media_trim_style = models.CharField(max_length=20, choices=TRIM_STYLE_CHOICES, default="no_encoding")
|
||||
timestamps = models.JSONField(null=False, blank=False, help_text="Timestamps for trimming")
|
||||
|
||||
def __str__(self):
|
||||
return f"Trim request for {self.media.title} ({self.status})"
|
||||
|
||||
|
||||
@receiver(post_save, sender=Media)
|
||||
def media_save(sender, instance, created, **kwargs):
|
||||
# media_file path is not set correctly until mode is saved
|
||||
@ -1479,13 +1591,17 @@ def media_file_pre_delete(sender, instance, **kwargs):
|
||||
tag.update_tag_media()
|
||||
|
||||
|
||||
@receiver(post_delete, sender=VideoChapterData)
|
||||
def videochapterdata_delete(sender, instance, **kwargs):
|
||||
helpers.rm_dir(instance.media.video_chapters_folder)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Media)
|
||||
def media_file_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
Deletes file from filesystem
|
||||
when corresponding `Media` object is deleted.
|
||||
"""
|
||||
|
||||
if instance.media_file:
|
||||
helpers.rm_file(instance.media_file.path)
|
||||
if instance.thumbnail:
|
||||
@ -1501,6 +1617,7 @@ def media_file_delete(sender, instance, **kwargs):
|
||||
if instance.hls_file:
|
||||
p = os.path.dirname(instance.hls_file)
|
||||
helpers.rm_dir(p)
|
||||
|
||||
instance.user.update_user_media()
|
||||
|
||||
# remove extra zombie thumbnails
|
||||
@ -1685,3 +1802,4 @@ def encoding_file_delete(sender, instance, **kwargs):
|
||||
instance.media.post_encode_actions(encoding=instance, action="delete")
|
||||
# delete local chunks, and remote chunks + media file. Only when the
|
||||
# last encoding of a media is complete
|
||||
|
||||
|
||||
@ -161,6 +161,7 @@ class SingleMediaSerializer(serializers.ModelSerializer):
|
||||
"hls_info",
|
||||
"license",
|
||||
"subtitles_info",
|
||||
"chapter_data",
|
||||
"ratings_info",
|
||||
"add_subtitle_url",
|
||||
"allow_download",
|
||||
|
||||
157
files/tasks.py
@ -17,6 +17,8 @@ from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files import File
|
||||
from django.db.models import Q
|
||||
from django.db.models.signals import post_save
|
||||
from contextlib import contextmanager
|
||||
|
||||
from actions.models import USER_MEDIA_ACTIONS, MediaAction
|
||||
from users.models import User
|
||||
@ -28,14 +30,27 @@ from .helpers import (
|
||||
create_temp_file,
|
||||
get_file_name,
|
||||
get_file_type,
|
||||
get_trim_timestamps,
|
||||
media_file_info,
|
||||
produce_ffmpeg_commands,
|
||||
produce_friendly_token,
|
||||
rm_file,
|
||||
run_command,
|
||||
trim_video_method,
|
||||
)
|
||||
from . import helpers
|
||||
from .methods import list_tasks, notify_users, pre_save_action, copy_video
|
||||
from .models import (
|
||||
Category,
|
||||
EncodeProfile,
|
||||
Encoding,
|
||||
Media,
|
||||
Rating,
|
||||
Tag,
|
||||
VideoChapterData,
|
||||
VideoTrimRequest,
|
||||
media_save
|
||||
)
|
||||
from .methods import list_tasks, notify_users, pre_save_action
|
||||
from .models import Category, EncodeProfile, Encoding, Media, Rating, Tag
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
@ -48,6 +63,15 @@ ERRORS_LIST = [
|
||||
]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def disable_signal(signal, receiver, sender):
|
||||
signal.disconnect(receiver, sender=sender)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
signal.connect(receiver, sender=sender)
|
||||
|
||||
|
||||
@task(name="chunkize_media", bind=True, queue="short_tasks", soft_time_limit=60 * 30 * 4)
|
||||
def chunkize_media(self, friendly_token, profiles, force=True):
|
||||
"""Break media in chunks and start encoding tasks"""
|
||||
@ -793,6 +817,135 @@ def remove_media_file(media_file=None):
|
||||
return True
|
||||
|
||||
|
||||
@task(name="produce_video_chapters", queue="short_tasks")
|
||||
def produce_video_chapters(chapter_id):
|
||||
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"
|
||||
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="video_trim_task", bind=True, queue="short_tasks")
|
||||
def video_trim_task(self, trim_request_id):
|
||||
# SOS: if at some point we move from ffmpeg copy, then this need be changed
|
||||
# to long_tasks
|
||||
try:
|
||||
trim_request = VideoTrimRequest.objects.get(id=trim_request_id)
|
||||
except VideoTrimRequest.DoesNotExist:
|
||||
logger.info(f"VideoTrimRequest with ID {trim_request_id} not found")
|
||||
return False
|
||||
|
||||
trim_request.status = "running"
|
||||
trim_request.save(update_fields=["status"])
|
||||
|
||||
timestamps_encodings = get_trim_timestamps(trim_request.media.trim_video_path, trim_request.timestamps)
|
||||
timestamps_original = get_trim_timestamps(trim_request.media.media_file.path, trim_request.timestamps)
|
||||
|
||||
if not timestamps_encodings:
|
||||
trim_request.status = "fail"
|
||||
trim_request.save(update_fields=["status"])
|
||||
return False
|
||||
|
||||
target_media = trim_request.media
|
||||
original_media = trim_request.media
|
||||
|
||||
# splitting the logic for single file and multiple files
|
||||
if trim_request.video_action in ["save_new", "replace"]:
|
||||
proceed_with_single_file = True
|
||||
if trim_request.video_action == "create_segments":
|
||||
if len(timestamps_encodings) == 1:
|
||||
proceed_with_single_file = True
|
||||
else:
|
||||
proceed_with_single_file = False
|
||||
|
||||
|
||||
if proceed_with_single_file:
|
||||
|
||||
if trim_request.video_action == "save_new" or trim_request.video_action == "create_segments" and len(timestamps_encodings) == 1:
|
||||
with disable_signal(post_save, media_save, Media):
|
||||
new_media = copy_video(original_media, copy_encodings=True)
|
||||
|
||||
target_media = new_media
|
||||
trim_request.media = new_media
|
||||
trim_request.save(update_fields=["media"])
|
||||
|
||||
# processing timestamps differently on encodings and original file, since they have different I-frames
|
||||
# the cut is made based on the I-frames
|
||||
|
||||
encodings = target_media.encodings.filter(status="success", profile__extension='mp4', chunk=False)
|
||||
for encoding in encodings:
|
||||
trim_result = trim_video_method(encoding.media_file.path, timestamps_encodings)
|
||||
if not trim_result:
|
||||
logger.info(f"Failed to trim encoding {encoding.id} for media {target_media.friendly_token}")
|
||||
|
||||
original_trim_result = trim_video_method(target_media.media_file.path, timestamps_original)
|
||||
if not original_trim_result:
|
||||
logger.info(f"Failed to trim original file for media {target_media.friendly_token}")
|
||||
|
||||
target_media.set_media_type()
|
||||
create_hls(target_media.friendly_token)
|
||||
|
||||
trim_request.status = "success"
|
||||
trim_request.save(update_fields=["status"])
|
||||
logger.info(f"Successfully processed video trim request {trim_request_id} for media {target_media.friendly_token}")
|
||||
|
||||
target_media.produce_thumbnails_from_video()
|
||||
target_media.produce_sprite_from_video()
|
||||
target_media.update_search_vector()
|
||||
|
||||
else:
|
||||
for i, timestamp in enumerate(timestamps_encodings, start=1):
|
||||
with disable_signal(post_save, media_save, Media):
|
||||
new_media = copy_video(original_media, title_suffix=f"(Trimmed) {i}", copy_encodings=True)
|
||||
|
||||
original_trim_result = trim_video_method(new_media.media_file.path, [timestamp])
|
||||
encodings = new_media.encodings.filter(status="success", profile__extension='mp4', chunk=False)
|
||||
for encoding in encodings:
|
||||
trim_result = trim_video_method(encoding.media_file.path, [timestamp])
|
||||
|
||||
new_media.set_media_type()
|
||||
create_hls(new_media.friendly_token)
|
||||
|
||||
new_media.produce_thumbnails_from_video()
|
||||
new_media.produce_sprite_from_video()
|
||||
new_media.update_search_vector()
|
||||
|
||||
trim_request.status = "success"
|
||||
trim_request.save(update_fields=["status"])
|
||||
logger.info(f"Successfully processed video trim request {trim_request_id} for media {target_media.friendly_token}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# TODO LIST
|
||||
# 1 chunks are deleted from original server when file is fully encoded.
|
||||
# however need to enter this logic in cases of fail as well
|
||||
|
||||
@ -16,6 +16,9 @@ urlpatterns = [
|
||||
re_path(r"^edit_subtitle", views.edit_subtitle, name="edit_subtitle"),
|
||||
re_path(r"^categories$", views.categories, name="categories"),
|
||||
re_path(r"^contact$", views.contact, name="contact"),
|
||||
re_path(r"^publish", views.publish_media, name="publish_media"),
|
||||
re_path(r"^edit_chapters", views.edit_chapters, name="edit_chapters"),
|
||||
re_path(r"^edit_video", views.edit_video, name="edit_video"),
|
||||
re_path(r"^edit", views.edit_media, name="edit_media"),
|
||||
re_path(r"^embed", views.embed_media, name="get_embed"),
|
||||
re_path(r"^featured$", views.featured_media),
|
||||
@ -62,6 +65,14 @@ urlpatterns = [
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)/actions$",
|
||||
views.MediaActions.as_view(),
|
||||
),
|
||||
re_path(
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)/chapters$",
|
||||
views.video_chapters,
|
||||
),
|
||||
re_path(
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)/trim_video$",
|
||||
views.trim_video,
|
||||
),
|
||||
re_path(r"^api/v1/categories$", views.CategoryList.as_view()),
|
||||
re_path(r"^api/v1/tags$", views.TagList.as_view()),
|
||||
re_path(r"^api/v1/comments$", views.CommentList.as_view()),
|
||||
|
||||
202
files/views.py
@ -1,5 +1,6 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from . import helpers
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
@ -7,9 +8,10 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.postgres.search import SearchQuery
|
||||
from django.core.mail import EmailMessage
|
||||
from django.db.models import Q
|
||||
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
||||
from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from drf_yasg import openapi as openapi
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework import permissions, status
|
||||
@ -36,14 +38,21 @@ from cms.version import VERSION
|
||||
from identity_providers.models import LoginOption
|
||||
from users.models import User
|
||||
|
||||
from .forms import ContactForm, EditSubtitleForm, MediaForm, SubtitleForm
|
||||
from .forms import (
|
||||
ContactForm,
|
||||
EditSubtitleForm,
|
||||
MediaMetadataForm,
|
||||
MediaPublishForm,
|
||||
SubtitleForm,
|
||||
)
|
||||
from .frontend_translations import translate_string
|
||||
from .helpers import clean_query, get_alphanumeric_only, produce_ffmpeg_commands
|
||||
from .methods import (
|
||||
check_comment_for_mention,
|
||||
create_video_trim_request,
|
||||
get_user_or_session,
|
||||
handle_video_chapters,
|
||||
is_mediacms_editor,
|
||||
is_mediacms_manager,
|
||||
list_tasks,
|
||||
notify_user_on_comment,
|
||||
show_recommended_media,
|
||||
@ -60,6 +69,7 @@ from .models import (
|
||||
PlaylistMedia,
|
||||
Subtitle,
|
||||
Tag,
|
||||
VideoTrimRequest,
|
||||
)
|
||||
from .serializers import (
|
||||
CategorySerializer,
|
||||
@ -73,7 +83,7 @@ from .serializers import (
|
||||
TagSerializer,
|
||||
)
|
||||
from .stop_words import STOP_WORDS
|
||||
from .tasks import save_user_action
|
||||
from .tasks import save_user_action, video_trim_task
|
||||
|
||||
VALID_USER_ACTIONS = [action for action, name in USER_MEDIA_ACTIONS]
|
||||
|
||||
@ -103,7 +113,7 @@ def add_subtitle(request):
|
||||
if not media:
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if not (request.user == media.user or is_mediacms_editor(request.user) or is_mediacms_manager(request.user)):
|
||||
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if request.method == "POST":
|
||||
@ -138,7 +148,7 @@ def edit_subtitle(request):
|
||||
if not subtitle:
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if not (request.user == subtitle.user or is_mediacms_editor(request.user) or is_mediacms_manager(request.user)):
|
||||
if not (request.user == subtitle.user or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
context = {"subtitle": subtitle, "action": action}
|
||||
@ -233,6 +243,41 @@ def history(request):
|
||||
return render(request, "cms/history.html", context)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def video_chapters(request, friendly_token):
|
||||
if not request.method == "POST":
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
media = Media.objects.filter(friendly_token=friendly_token).first()
|
||||
|
||||
if not media:
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)["chapters"]
|
||||
chapters = []
|
||||
for _, chapter_data in enumerate(data):
|
||||
start_time = chapter_data.get('start')
|
||||
title = chapter_data.get('title')
|
||||
if start_time and title:
|
||||
chapters.append(
|
||||
{
|
||||
'start': start_time,
|
||||
'title': 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)
|
||||
|
||||
ret = handle_video_chapters(media, chapters)
|
||||
|
||||
return JsonResponse(ret, safe=False)
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_media(request):
|
||||
"""Edit a media view"""
|
||||
@ -245,10 +290,10 @@ def edit_media(request):
|
||||
if not media:
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if not (request.user == media.user or is_mediacms_editor(request.user) or is_mediacms_manager(request.user)):
|
||||
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
if request.method == "POST":
|
||||
form = MediaForm(request.user, request.POST, request.FILES, instance=media)
|
||||
form = MediaMetadataForm(request.user, request.POST, request.FILES, instance=media)
|
||||
if form.is_valid():
|
||||
media = form.save()
|
||||
for tag in media.tags.all():
|
||||
@ -267,11 +312,140 @@ def edit_media(request):
|
||||
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited"))
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
else:
|
||||
form = MediaForm(request.user, instance=media)
|
||||
form = MediaMetadataForm(request.user, instance=media)
|
||||
return render(
|
||||
request,
|
||||
"cms/edit_media.html",
|
||||
{"form": form, "add_subtitle_url": media.add_subtitle_url},
|
||||
{"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def publish_media(request):
|
||||
"""Publish media"""
|
||||
|
||||
friendly_token = request.GET.get("m", "").strip()
|
||||
if not friendly_token:
|
||||
return HttpResponseRedirect("/")
|
||||
media = Media.objects.filter(friendly_token=friendly_token).first()
|
||||
|
||||
if not media:
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if request.method == "POST":
|
||||
form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media)
|
||||
if form.is_valid():
|
||||
media = form.save()
|
||||
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited"))
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
else:
|
||||
form = MediaPublishForm(request.user, instance=media)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"cms/publish_media.html",
|
||||
{"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_chapters(request):
|
||||
"""Edit chapters"""
|
||||
|
||||
friendly_token = request.GET.get("m", "").strip()
|
||||
if not friendly_token:
|
||||
return HttpResponseRedirect("/")
|
||||
media = Media.objects.filter(friendly_token=friendly_token).first()
|
||||
|
||||
if not media:
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
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},
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def trim_video(request, friendly_token):
|
||||
if not request.method == "POST":
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
media = Media.objects.filter(friendly_token=friendly_token).first()
|
||||
|
||||
if not media:
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
existing_requests = VideoTrimRequest.objects.filter(
|
||||
media=media,
|
||||
status__in=["initial", "running"]
|
||||
).exists()
|
||||
|
||||
if existing_requests:
|
||||
return JsonResponse({"success": False, "error": "A trim request is already in progress for this video"}, status=400)
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
video_trim_request = create_video_trim_request(media, data)
|
||||
video_trim_task.delay(video_trim_request.id)
|
||||
ret = {"success": True, "request_id": video_trim_request.id}
|
||||
return JsonResponse(ret, safe=False, status=200)
|
||||
except Exception as e:
|
||||
ret = {"success": False, "error": "Incorrect request data"}
|
||||
return JsonResponse(ret, safe=False, status=400)
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_video(request):
|
||||
"""Edit video"""
|
||||
|
||||
friendly_token = request.GET.get("m", "").strip()
|
||||
if not friendly_token:
|
||||
return HttpResponseRedirect("/")
|
||||
media = Media.objects.filter(friendly_token=friendly_token).first()
|
||||
|
||||
if not media:
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if not media.media_type == "video":
|
||||
messages.add_message(request, messages.INFO, "Media is not video")
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
|
||||
|
||||
# Check if there's a running trim request
|
||||
running_trim_request = VideoTrimRequest.objects.filter(
|
||||
media=media,
|
||||
status__in=["initial", "running"]
|
||||
).exists()
|
||||
|
||||
if running_trim_request:
|
||||
messages.add_message(request, messages.INFO, "Video trim request is already running")
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
|
||||
media_file_path = media.trim_video_url
|
||||
|
||||
if not media_file_path:
|
||||
messages.add_message(request, messages.INFO, "Media processing has not finished yet")
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
|
||||
return render(
|
||||
request,
|
||||
"cms/edit_video.html",
|
||||
{"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": media_file_path},
|
||||
)
|
||||
|
||||
|
||||
@ -428,7 +602,7 @@ def view_media(request):
|
||||
context["CAN_DELETE_COMMENTS"] = False
|
||||
|
||||
if request.user.is_authenticated:
|
||||
if (media.user.id == request.user.id) or is_mediacms_editor(request.user) or is_mediacms_manager(request.user):
|
||||
if media.user.id == request.user.id or is_mediacms_editor(request.user):
|
||||
context["CAN_DELETE_MEDIA"] = True
|
||||
context["CAN_EDIT_MEDIA"] = True
|
||||
context["CAN_DELETE_COMMENTS"] = True
|
||||
@ -621,7 +795,7 @@ class MediaDetail(APIView):
|
||||
if isinstance(media, Response):
|
||||
return media
|
||||
|
||||
if not (is_mediacms_editor(request.user) or is_mediacms_manager(request.user)):
|
||||
if not is_mediacms_editor(request.user):
|
||||
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
action = request.data.get("type")
|
||||
@ -738,7 +912,7 @@ class MediaActions(APIView):
|
||||
def get(self, request, friendly_token, format=None):
|
||||
# show date and reason for each time media was reported
|
||||
media = self.get_object(friendly_token)
|
||||
if not (request.user == media.user or is_mediacms_editor(request.user) or is_mediacms_manager(request.user)):
|
||||
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
||||
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if isinstance(media, Response):
|
||||
|
||||
1
frontend-tools/static/video_editor/video-editor.css
Normal file
203
frontend-tools/static/video_editor/video-editor.js
Normal file
1
frontend-tools/static/video_editor/video-editor.js.map
Normal file
7
frontend-tools/video-editor/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
server/public
|
||||
vite.config.ts.*
|
||||
*.tar.gz
|
||||
yt.readme.md
|
||||
1
frontend-tools/video-editor/.prettierignore
Normal file
@ -0,0 +1 @@
|
||||
*
|
||||
132
frontend-tools/video-editor/README.md
Normal file
@ -0,0 +1,132 @@
|
||||
# MediaCMS Video Editor
|
||||
|
||||
A modern browser-based video editing tool built with React and TypeScript that integrates with MediaCMS. The editor allows users to trim, split, and manage video segments with a timeline interface.
|
||||
|
||||
## Features
|
||||
|
||||
- ⏱️ Trim video start and end points
|
||||
- ✂️ Split videos into multiple segments
|
||||
- 👁️ Preview individual segments or the full edited video
|
||||
- 🔍 Zoom timeline for precise editing
|
||||
- 🔄 Undo/redo support for all editing operations
|
||||
- 🔊 Audio mute controls
|
||||
- 💾 Save edits directly to MediaCMS
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Vite
|
||||
- Tailwind CSS
|
||||
- Express (for development server)
|
||||
- Drizzle ORM
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (v20+) - Use `nvm use 20` if you have nvm installed
|
||||
- Yarn or npm package manager
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Navigate to the video editor directory
|
||||
cd frontend-tools/video-editor
|
||||
|
||||
# Install dependencies with Yarn
|
||||
yarn install
|
||||
|
||||
# Or with npm
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
The video 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 video 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 video 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
|
||||
|
||||
- `/src` - Source code
|
||||
- `/components` - React components
|
||||
- `/hooks` - Custom React hooks
|
||||
- `/lib` - Utility functions and helpers
|
||||
- `/services` - API services
|
||||
- `/styles` - CSS and style definitions
|
||||
|
||||
## API Integration
|
||||
|
||||
The video editor interfaces with MediaCMS through a set of API endpoints for retrieving and saving video edits.
|
||||
15
frontend-tools/video-editor/client/index.html
Normal file
@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="video-editor-trim-root"></div>
|
||||
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
100
frontend-tools/video-editor/client/src/App.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import VideoPlayer from "@/components/VideoPlayer";
|
||||
import TimelineControls from "@/components/TimelineControls";
|
||||
import EditingTools from "@/components/EditingTools";
|
||||
import ClipSegments from "@/components/ClipSegments";
|
||||
import useVideoTrimmer from "@/hooks/useVideoTrimmer";
|
||||
|
||||
const App = () => {
|
||||
const {
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
isPreviewMode,
|
||||
isMuted,
|
||||
thumbnails,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
splitPoints,
|
||||
zoomLevel,
|
||||
clipSegments,
|
||||
historyPosition,
|
||||
history,
|
||||
hasUnsavedChanges,
|
||||
playPauseVideo,
|
||||
seekVideo,
|
||||
handleTrimStartChange,
|
||||
handleTrimEndChange,
|
||||
handleSplit,
|
||||
handleReset,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
handlePreview,
|
||||
handlePlay,
|
||||
handleZoomChange,
|
||||
toggleMute,
|
||||
handleSave,
|
||||
handleSaveACopy,
|
||||
handleSaveSegments,
|
||||
} = useVideoTrimmer();
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-screen">
|
||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||
|
||||
{/* Video Player */}
|
||||
<VideoPlayer
|
||||
videoRef={videoRef}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isPlaying={isPlaying}
|
||||
isMuted={isMuted}
|
||||
onPlayPause={playPauseVideo}
|
||||
onSeek={seekVideo}
|
||||
onToggleMute={toggleMute}
|
||||
/>
|
||||
|
||||
{/* Editing Tools */}
|
||||
<EditingTools
|
||||
onSplit={handleSplit}
|
||||
onReset={handleReset}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onPreview={handlePreview}
|
||||
onPlay={handlePlay}
|
||||
isPreviewMode={isPreviewMode}
|
||||
isPlaying={isPlaying}
|
||||
canUndo={historyPosition > 0}
|
||||
canRedo={historyPosition < history.length - 1}
|
||||
/>
|
||||
|
||||
{/* Timeline Controls */}
|
||||
<TimelineControls
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
thumbnails={thumbnails}
|
||||
trimStart={trimStart}
|
||||
trimEnd={trimEnd}
|
||||
splitPoints={splitPoints}
|
||||
zoomLevel={zoomLevel}
|
||||
clipSegments={clipSegments}
|
||||
onTrimStartChange={handleTrimStartChange}
|
||||
onTrimEndChange={handleTrimEndChange}
|
||||
onZoomChange={handleZoomChange}
|
||||
onSeek={seekVideo}
|
||||
videoRef={videoRef}
|
||||
onSave={handleSave}
|
||||
onSaveACopy={handleSaveACopy}
|
||||
onSaveSegments={handleSaveSegments}
|
||||
isPreviewMode={isPreviewMode}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
/>
|
||||
|
||||
{/* Clip Segments */}
|
||||
<ClipSegments segments={clipSegments} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M208.15,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C218.47,375.57,213.85,380.19,208.15,380.19z"/></g><g><path class="st0" d="M395.04,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C405.36,375.57,400.74,380.19,395.04,380.19z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 832 B |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 813 B |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 818 B |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 597 B |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 611 B |
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
|
||||
<g transform="translate(6, 0)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 397 B |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/></g></svg>
|
||||
|
After Width: | Height: | Size: 359 B |
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM12.29,20.29l1.42,1.42,5-5a1,1,0,0,0,0-1.42l-5-5-1.42,1.42L15.59,15H5v2H15.59Z" id="login_account_enter_door"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 364 B |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM12.29,20.29l1.42,1.42,5-5a1,1,0,0,0,0-1.42l-5-5-1.42,1.42L15.59,15H5v2H15.59Z" id="login_account_enter_door"/></g></svg>
|
||||
|
After Width: | Height: | Size: 359 B |
@ -0,0 +1,86 @@
|
||||
import { formatTime, formatLongTime } from "@/lib/timeUtils";
|
||||
import '../styles/ClipSegments.css';
|
||||
|
||||
export interface Segment {
|
||||
id: number;
|
||||
name: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
interface ClipSegmentsProps {
|
||||
segments: Segment[];
|
||||
}
|
||||
|
||||
const ClipSegments = ({ segments }: 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}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="clip-segments-container">
|
||||
<h3 className="clip-segments-title">Clip Segments</h3>
|
||||
|
||||
{sortedSegments.map((segment, index) => (
|
||||
<div
|
||||
key={segment.id}
|
||||
className={`segment-item ${getSegmentColorClass(index)}`}
|
||||
>
|
||||
<div className="segment-content">
|
||||
<div
|
||||
className="segment-thumbnail"
|
||||
style={{ backgroundImage: `url(${segment.thumbnail})` }}
|
||||
></div>
|
||||
<div className="segment-info">
|
||||
<div className="segment-title">
|
||||
Segment {index + 1}
|
||||
</div>
|
||||
<div className="segment-time">
|
||||
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
|
||||
</div>
|
||||
<div className="segment-duration">
|
||||
Duration: {formatLongTime(segment.endTime - segment.startTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="segment-actions">
|
||||
<button
|
||||
className="delete-button"
|
||||
aria-label="Delete Segment"
|
||||
data-tooltip="Delete this segment"
|
||||
onClick={() => handleDeleteSegment(segment.id)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sortedSegments.length === 0 && (
|
||||
<div className="empty-message">
|
||||
No segments created yet. Use the split button to create segments.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClipSegments;
|
||||
@ -0,0 +1,151 @@
|
||||
import '../styles/EditingTools.css';
|
||||
|
||||
interface EditingToolsProps {
|
||||
onSplit: () => void;
|
||||
onReset: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPreview: () => void;
|
||||
onPlay: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isPreviewMode?: boolean;
|
||||
isPlaying?: boolean;
|
||||
}
|
||||
|
||||
const EditingTools = ({
|
||||
onSplit,
|
||||
onReset,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPreview,
|
||||
onPlay,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isPreviewMode = false,
|
||||
isPlaying = false,
|
||||
}: EditingToolsProps) => {
|
||||
return (
|
||||
<div className="editing-tools-container">
|
||||
<div className="flex-container single-row">
|
||||
{/* Left side - Play buttons group */}
|
||||
<div className="button-group play-buttons-group">
|
||||
{/* Play Preview button */}
|
||||
<button
|
||||
className="button preview-button"
|
||||
onClick={onPreview}
|
||||
data-tooltip={isPreviewMode ? "Stop preview playback" : "Play only segments (skips gaps between segments)"}
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
{isPreviewMode ? (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="10" y1="15" x2="10" y2="9" />
|
||||
<line x1="14" y1="15" x2="14" y2="9" />
|
||||
</svg>
|
||||
<span className="full-text">Stop Preview</span>
|
||||
<span className="short-text">Stop</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
<span className="full-text">Play Preview</span>
|
||||
<span className="short-text">Preview</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Standard Play button (only shown when not in preview mode) */}
|
||||
{!isPreviewMode && (
|
||||
<button
|
||||
className="button play-button"
|
||||
onClick={onPlay}
|
||||
data-tooltip={isPlaying ? "Pause video" : "Play full video"}
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="10" y1="15" x2="10" y2="9" />
|
||||
<line x1="14" y1="15" x2="14" y2="9" />
|
||||
</svg>
|
||||
<span className="full-text">Pause</span>
|
||||
<span className="short-text">Pause</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
<span className="full-text">Play</span>
|
||||
<span className="short-text">Play</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Preview mode message (replaces play button) */}
|
||||
{isPreviewMode && (
|
||||
<div className="preview-mode-message">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="8" />
|
||||
</svg>
|
||||
Preview Mode
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side - Editing tools */}
|
||||
<div className="button-group secondary">
|
||||
<button
|
||||
className="button"
|
||||
aria-label="Undo"
|
||||
data-tooltip="Undo last action"
|
||||
disabled={!canUndo}
|
||||
onClick={onUndo}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 14 4 9l5-5"/>
|
||||
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/>
|
||||
</svg>
|
||||
<span className="button-text">Undo</span>
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
aria-label="Redo"
|
||||
data-tooltip="Redo last undone action"
|
||||
disabled={!canRedo}
|
||||
onClick={onRedo}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m15 14 5-5-5-5"/>
|
||||
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13"/>
|
||||
</svg>
|
||||
<span className="button-text">Redo</span>
|
||||
</button>
|
||||
<div className="divider"></div>
|
||||
<button
|
||||
className="button"
|
||||
onClick={onReset}
|
||||
data-tooltip="Reset to full video"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="reset-text">Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditingTools;
|
||||
90
frontend-tools/video-editor/client/src/components/Modal.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
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<ModalProps> = ({
|
||||
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 (
|
||||
<div className="modal-overlay" onClick={handleClickOutside}>
|
||||
<div className="modal-container" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button
|
||||
className="modal-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{actions && (
|
||||
<div className="modal-actions">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@ -0,0 +1,163 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { formatTime } from "@/lib/timeUtils";
|
||||
import '../styles/VideoPlayer.css';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
isMuted?: boolean;
|
||||
onPlayPause: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
onToggleMute?: () => void;
|
||||
}
|
||||
|
||||
const VideoPlayer = ({
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
isMuted = false,
|
||||
onPlayPause,
|
||||
onSeek,
|
||||
onToggleMute
|
||||
}: VideoPlayerProps) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const sampleVideoUrl = typeof window !== 'undefined' &&
|
||||
(window as any).MEDIA_DATA?.videoUrl ||
|
||||
"https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
||||
|
||||
// Add iOS-specific attributes to prevent fullscreen playback
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
// These attributes need to be set directly on the DOM element
|
||||
// for iOS Safari to respect inline playback
|
||||
videoRef.current.setAttribute('playsinline', 'true');
|
||||
videoRef.current.setAttribute('webkit-playsinline', 'true');
|
||||
videoRef.current.setAttribute('x-webkit-airplay', 'allow');
|
||||
}
|
||||
}, [videoRef]);
|
||||
|
||||
// Jump 10 seconds forward
|
||||
const handleForward = () => {
|
||||
onSeek(Math.min(currentTime + 10, duration));
|
||||
};
|
||||
|
||||
// Jump 10 seconds backward
|
||||
const handleBackward = () => {
|
||||
onSeek(Math.max(currentTime - 10, 0));
|
||||
};
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
// Handle click on progress bar
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (progressRef.current) {
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const clickPosition = (e.clientX - rect.left) / rect.width;
|
||||
onSeek(duration * clickPosition);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle toggling fullscreen
|
||||
const handleFullscreen = () => {
|
||||
if (videoRef.current) {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
videoRef.current.requestFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="video-player-container">
|
||||
<video
|
||||
ref={videoRef}
|
||||
preload="auto"
|
||||
crossOrigin="anonymous"
|
||||
onClick={onPlayPause}
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x-webkit-airplay="allow"
|
||||
controls={false}
|
||||
muted={isMuted}
|
||||
>
|
||||
<source src={sampleVideoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
|
||||
{/* Play/Pause Indicator (shows based on current state) */}
|
||||
<div className={`play-pause-indicator ${isPlaying ? 'pause-icon' : 'play-icon'}`}></div>
|
||||
|
||||
{/* Video Controls Overlay */}
|
||||
<div className="video-controls">
|
||||
{/* Time and Duration */}
|
||||
<div className="video-time-display">
|
||||
<span className="video-current-time">{formatTime(currentTime)}</span>
|
||||
<span className="video-duration">/ {formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
ref={progressRef}
|
||||
className="video-progress"
|
||||
onClick={handleProgressClick}
|
||||
>
|
||||
<div
|
||||
className="video-progress-fill"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
></div>
|
||||
<div
|
||||
className="video-scrubber"
|
||||
style={{ left: `${progressPercentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Controls - Mute and Fullscreen buttons */}
|
||||
<div className="video-controls-buttons">
|
||||
{/* Mute/Unmute Button */}
|
||||
{onToggleMute && (
|
||||
<button
|
||||
className="mute-button"
|
||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||
onClick={onToggleMute}
|
||||
data-tooltip={isMuted ? "Unmute" : "Mute"}
|
||||
>
|
||||
{isMuted ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
|
||||
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
|
||||
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fullscreen Button */}
|
||||
<button
|
||||
className="fullscreen-button"
|
||||
aria-label="Fullscreen"
|
||||
onClick={handleFullscreen}
|
||||
data-tooltip="Toggle fullscreen"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
||||
931
frontend-tools/video-editor/client/src/hooks/useVideoTrimmer.tsx
Normal file
@ -0,0 +1,931 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { generateThumbnail } from "@/lib/videoUtils";
|
||||
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 useVideoTrimmer = () => {
|
||||
// Video element reference and state
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
|
||||
// Preview mode state for playing only segments
|
||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||
const [previewSegmentIndex, setPreviewSegmentIndex] = useState(0);
|
||||
|
||||
// Timeline state
|
||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
||||
const [trimStart, setTrimStart] = useState(0);
|
||||
const [trimEnd, setTrimEnd] = useState(0);
|
||||
const [splitPoints, setSplitPoints] = useState<number[]>([]);
|
||||
const [zoomLevel, setZoomLevel] = useState(1); // Start with 1x zoom level
|
||||
|
||||
// Clip segments state
|
||||
const [clipSegments, setClipSegments] = useState<Segment[]>([]);
|
||||
|
||||
// History state for undo/redo
|
||||
const [history, setHistory] = useState<EditorState[]>([]);
|
||||
const [historyPosition, setHistoryPosition] = useState(-1);
|
||||
|
||||
// Track unsaved changes
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Set up page unload warning
|
||||
useEffect(() => {
|
||||
// Event handler for beforeunload
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsavedChanges) {
|
||||
// Standard way of showing a confirmation dialog before leaving
|
||||
const message = 'Your edits will get lost if you leave the page. Do you want to continue?';
|
||||
e.preventDefault();
|
||||
e.returnValue = message; // Chrome requires returnValue to be set
|
||||
return message; // For other browsers
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
// Initialize video event listeners
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
setDuration(video.duration);
|
||||
setTrimEnd(video.duration);
|
||||
|
||||
// Generate placeholders and create initial segment
|
||||
const initializeEditor = async () => {
|
||||
// Generate thumbnail for initial segment
|
||||
const segmentThumbnail = await generateThumbnail(video, video.duration / 2);
|
||||
|
||||
// Create an initial segment that spans the entire video
|
||||
const initialSegment: Segment = {
|
||||
id: 1,
|
||||
name: "segment",
|
||||
startTime: 0,
|
||||
endTime: video.duration,
|
||||
thumbnail: segmentThumbnail
|
||||
};
|
||||
|
||||
// Initialize history state with the full-length segment
|
||||
const initialState: EditorState = {
|
||||
trimStart: 0,
|
||||
trimEnd: video.duration,
|
||||
splitPoints: [],
|
||||
clipSegments: [initialSegment]
|
||||
};
|
||||
|
||||
setHistory([initialState]);
|
||||
setHistoryPosition(0);
|
||||
setClipSegments([initialSegment]);
|
||||
|
||||
// Generate timeline thumbnails
|
||||
const count = 6;
|
||||
const interval = video.duration / count;
|
||||
const placeholders: string[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const time = interval * i + interval / 2;
|
||||
const thumbnail = await generateThumbnail(video, time);
|
||||
placeholders.push(thumbnail);
|
||||
}
|
||||
|
||||
setThumbnails(placeholders);
|
||||
};
|
||||
|
||||
initializeEditor();
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
setCurrentTime(video.currentTime);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
// Only update isPlaying if we're not in preview mode
|
||||
if (!isPreviewMode) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
// Only update isPlaying if we're not in preview mode
|
||||
if (!isPreviewMode) {
|
||||
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);
|
||||
|
||||
return () => {
|
||||
// Remove event listeners
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('ended', handleEnded);
|
||||
};
|
||||
}, [isPreviewMode]);
|
||||
|
||||
// Play/pause video
|
||||
const playPauseVideo = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
} else {
|
||||
// If at the end of the trim range, reset to the beginning
|
||||
if (video.currentTime >= trimEnd) {
|
||||
video.currentTime = trimStart;
|
||||
}
|
||||
video.play()
|
||||
.then(() => {
|
||||
// Play started successfully
|
||||
})
|
||||
.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;
|
||||
|
||||
// Track if the video was playing before seeking
|
||||
const wasPlaying = !video.paused;
|
||||
|
||||
// Store current preview mode state to preserve it
|
||||
const wasInPreviewMode = isPreviewMode;
|
||||
|
||||
// Update the video position
|
||||
video.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
|
||||
// Find segment at this position for preview mode playback
|
||||
if (wasInPreviewMode) {
|
||||
const segmentAtPosition = clipSegments.find(
|
||||
seg => time >= seg.startTime && time <= seg.endTime
|
||||
);
|
||||
|
||||
if (segmentAtPosition) {
|
||||
// Update the active segment index in preview mode
|
||||
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
const newSegmentIndex = orderedSegments.findIndex(seg => seg.id === segmentAtPosition.id);
|
||||
if (newSegmentIndex !== -1) {
|
||||
setPreviewSegmentIndex(newSegmentIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resume playback in two scenarios:
|
||||
// 1. If it was playing before (regular mode)
|
||||
// 2. If we're in preview mode (regardless of previous state)
|
||||
if (wasPlaying || wasInPreviewMode) {
|
||||
// Ensure preview mode stays on if it was on before
|
||||
if (wasInPreviewMode) {
|
||||
setIsPreviewMode(true);
|
||||
}
|
||||
|
||||
// Play immediately without delay
|
||||
video.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true); // Update state to reflect we're playing
|
||||
// "Resumed playback after seeking in " + (wasInPreviewMode ? "preview" : "regular") + " mode"
|
||||
})
|
||||
.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);
|
||||
|
||||
// Create first half of the split segment - no thumbnail needed
|
||||
const firstHalf: Segment = {
|
||||
id: Date.now(),
|
||||
name: `${segmentToSplit.name}-A`,
|
||||
startTime: segmentToSplit.startTime,
|
||||
endTime: timeToSplit,
|
||||
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
|
||||
};
|
||||
|
||||
// Create second half of the split segment - no thumbnail needed
|
||||
const secondHalf: Segment = {
|
||||
id: Date.now() + 1,
|
||||
name: `${segmentToSplit.name}-B`,
|
||||
startTime: timeToSplit,
|
||||
endTime: segmentToSplit.endTime,
|
||||
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
|
||||
};
|
||||
|
||||
// Add the new segments
|
||||
newSegments.push(firstHalf, secondHalf);
|
||||
|
||||
// Sort segments by start time
|
||||
newSegments.sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Update state
|
||||
setClipSegments(newSegments);
|
||||
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 all segments are deleted, create a new full video segment
|
||||
if (newSegments.length === 0 && videoRef.current) {
|
||||
// Create a new default segment that spans the entire video
|
||||
// No need to generate a thumbnail - we'll use dynamic colors
|
||||
const defaultSegment: Segment = {
|
||||
id: Date.now(),
|
||||
name: "segment",
|
||||
startTime: 0,
|
||||
endTime: videoRef.current.duration,
|
||||
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
|
||||
};
|
||||
|
||||
// Reset the trim points as well
|
||||
setTrimStart(0);
|
||||
setTrimEnd(videoRef.current.duration);
|
||||
setSplitPoints([]);
|
||||
setClipSegments([defaultSegment]);
|
||||
} else {
|
||||
// Just update the segments normally
|
||||
setClipSegments(newSegments);
|
||||
}
|
||||
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]);
|
||||
|
||||
// Preview mode effect to handle playing only segments
|
||||
useEffect(() => {
|
||||
if (!isPreviewMode || !videoRef.current) return;
|
||||
|
||||
// logger.debug("Preview mode effect running, previewSegmentIndex:", previewSegmentIndex);
|
||||
|
||||
// Sort segments by start time
|
||||
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Check if we've reached the end of the current segment
|
||||
const handleSegmentPlayback = () => {
|
||||
if (!isPreviewMode || !videoRef.current) return;
|
||||
|
||||
const currentSegment = orderedSegments[previewSegmentIndex];
|
||||
if (!currentSegment) return;
|
||||
|
||||
// Make sure we're inside the current segment
|
||||
const currentTime = videoRef.current.currentTime;
|
||||
|
||||
// If the current time is before the segment start, move to the segment start
|
||||
if (currentTime < currentSegment.startTime) {
|
||||
videoRef.current.currentTime = currentSegment.startTime;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we've reached the end of the current segment
|
||||
if (currentTime >= currentSegment.endTime - 0.05) { // Slightly before the end to ensure smooth transition
|
||||
// Move to the next segment if available
|
||||
if (previewSegmentIndex < orderedSegments.length - 1) {
|
||||
const nextSegment = orderedSegments[previewSegmentIndex + 1];
|
||||
|
||||
// Play through the next segment immediately
|
||||
videoRef.current.currentTime = nextSegment.startTime;
|
||||
setPreviewSegmentIndex(previewSegmentIndex + 1);
|
||||
logger.debug("Moving to next segment in preview:", nextSegment.name,
|
||||
"from", formatDetailedTime(currentSegment.endTime),
|
||||
"to", formatDetailedTime(nextSegment.startTime));
|
||||
|
||||
// Ensure playback continues
|
||||
videoRef.current.play().catch(err => {
|
||||
console.error("Error continuing preview playback:", err);
|
||||
});
|
||||
} else {
|
||||
// End of all segments, loop back to the first segment
|
||||
logger.debug("End of all segments in preview, looping back to first");
|
||||
videoRef.current.currentTime = orderedSegments[0].startTime;
|
||||
setPreviewSegmentIndex(0);
|
||||
|
||||
// Ensure playback continues
|
||||
videoRef.current.play().catch(err => {
|
||||
console.error("Error restarting preview playback:", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener for timeupdate to check segment boundaries
|
||||
videoRef.current.addEventListener('timeupdate', handleSegmentPlayback);
|
||||
|
||||
return () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.removeEventListener('timeupdate', handleSegmentPlayback);
|
||||
}
|
||||
};
|
||||
}, [isPreviewMode, previewSegmentIndex, clipSegments]);
|
||||
|
||||
// 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) {
|
||||
// No need to generate thumbnails - we'll use dynamic colors
|
||||
newSegments.push({
|
||||
id: Date.now() + i,
|
||||
name: `Segment ${i + 1}`,
|
||||
startTime,
|
||||
endTime,
|
||||
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
|
||||
});
|
||||
|
||||
startTime = endTime;
|
||||
}
|
||||
}
|
||||
|
||||
setClipSegments(newSegments);
|
||||
saveState('create_split_points');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle reset of all edits
|
||||
const handleReset = async () => {
|
||||
setTrimStart(0);
|
||||
setTrimEnd(duration);
|
||||
setSplitPoints([]);
|
||||
|
||||
// Create a new default segment that spans the entire video
|
||||
if (!videoRef.current) return;
|
||||
|
||||
// No need to generate thumbnails - we'll use dynamic colors
|
||||
const defaultSegment: Segment = {
|
||||
id: Date.now(),
|
||||
name: "segment",
|
||||
startTime: 0,
|
||||
endTime: duration,
|
||||
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
|
||||
};
|
||||
|
||||
setClipSegments([defaultSegment]);
|
||||
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);
|
||||
} 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);
|
||||
} else {
|
||||
logger.debug("Cannot redo: at latest history position");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle playing the preview of all segments
|
||||
const handlePreview = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video || clipSegments.length === 0) return;
|
||||
|
||||
// If preview is already active, turn it off
|
||||
if (isPreviewMode) {
|
||||
setIsPreviewMode(false);
|
||||
|
||||
// Always pause the video when exiting preview mode
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
|
||||
logger.debug("Exiting preview mode - video paused");
|
||||
return;
|
||||
}
|
||||
|
||||
// If normal playback is happening, remember that state but pause it
|
||||
const wasPlaying = isPlaying;
|
||||
if (wasPlaying) {
|
||||
video.pause();
|
||||
}
|
||||
|
||||
// Sort segments by start time to ensure proper playback order
|
||||
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
if (orderedSegments.length === 0) return;
|
||||
|
||||
// Set the preview mode flag before starting playback
|
||||
setIsPreviewMode(true);
|
||||
logger.debug("Entering preview mode");
|
||||
|
||||
// Set the first segment as the current one in the preview sequence
|
||||
setPreviewSegmentIndex(0);
|
||||
|
||||
// Start preview mode by playing the first segment
|
||||
video.currentTime = orderedSegments[0].startTime;
|
||||
|
||||
// Force a small delay before playing to ensure the preview mode state is set
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play()
|
||||
.then(() => {
|
||||
logger.debug("Preview started successfully");
|
||||
setIsPlaying(true);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error starting preview:", err);
|
||||
setIsPreviewMode(false); // Reset preview mode if play fails
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
}, 50); // Short delay to ensure state updates have processed
|
||||
};
|
||||
|
||||
// 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 in preview mode, exit it before toggling normal play
|
||||
if (isPreviewMode) {
|
||||
setIsPreviewMode(false);
|
||||
// Don't immediately start playing when exiting preview mode
|
||||
// Just update the state and return
|
||||
setIsPlaying(false);
|
||||
video.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
// Pause the video
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
// Play the video from current position with proper promise handling
|
||||
video.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
})
|
||||
.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 save action
|
||||
const handleSave = () => {
|
||||
// Sort segments chronologically by start time before saving
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Create the JSON data for saving
|
||||
const saveData = {
|
||||
type: "save",
|
||||
segments: sortedSegments.map(segment => ({
|
||||
startTime: formatDetailedTime(segment.startTime),
|
||||
endTime: formatDetailedTime(segment.endTime)
|
||||
}))
|
||||
};
|
||||
|
||||
// Display JSON in alert (for demonstration purposes)
|
||||
alert(JSON.stringify(saveData, null, 2));
|
||||
|
||||
// Mark as saved - no unsaved changes
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Debug message
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug("Changes saved - reset unsaved changes flag");
|
||||
}
|
||||
|
||||
// Save to history with special "save" action to mark saved state
|
||||
saveState('save');
|
||||
|
||||
// In a real implementation, this would make a POST request to save the data
|
||||
// logger.debug("Save data:", saveData);
|
||||
};
|
||||
|
||||
// Handle save a copy action
|
||||
const handleSaveACopy = () => {
|
||||
// Sort segments chronologically by start time before saving
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Create the JSON data for saving as a copy
|
||||
const saveData = {
|
||||
type: "save_as_a_copy",
|
||||
segments: sortedSegments.map(segment => ({
|
||||
startTime: formatDetailedTime(segment.startTime),
|
||||
endTime: formatDetailedTime(segment.endTime)
|
||||
}))
|
||||
};
|
||||
|
||||
// Display JSON in alert (for demonstration purposes)
|
||||
alert(JSON.stringify(saveData, null, 2));
|
||||
|
||||
// Mark as saved - no unsaved changes
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Debug message
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug("Changes saved as copy - reset unsaved changes flag");
|
||||
}
|
||||
|
||||
// Save to history with special "save_copy" action to mark saved state
|
||||
saveState('save_copy');
|
||||
};
|
||||
|
||||
// Handle save segments individually action
|
||||
const handleSaveSegments = () => {
|
||||
// Sort segments chronologically by start time before saving
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Create the JSON data for saving individual segments
|
||||
const saveData = {
|
||||
type: "save_segments",
|
||||
segments: sortedSegments.map(segment => ({
|
||||
name: segment.name,
|
||||
startTime: formatDetailedTime(segment.startTime),
|
||||
endTime: formatDetailedTime(segment.endTime)
|
||||
}))
|
||||
};
|
||||
|
||||
// Display JSON in alert (for demonstration purposes)
|
||||
alert(JSON.stringify(saveData, null, 2));
|
||||
|
||||
// Mark as saved - no unsaved changes
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Debug message
|
||||
logger.debug("All segments saved individually - reset unsaved changes flag");
|
||||
|
||||
// Save to history with special "save_segments" action to mark saved state
|
||||
saveState('save_segments');
|
||||
};
|
||||
|
||||
return {
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
isPreviewMode,
|
||||
thumbnails,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
splitPoints,
|
||||
zoomLevel,
|
||||
clipSegments,
|
||||
historyPosition,
|
||||
history,
|
||||
isMuted,
|
||||
hasUnsavedChanges, // Add unsaved changes flag to the return object
|
||||
playPauseVideo,
|
||||
seekVideo,
|
||||
handleTrimStartChange,
|
||||
handleTrimEndChange,
|
||||
handleSplit,
|
||||
handleReset,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
handlePreview,
|
||||
handlePlay,
|
||||
handleZoomChange,
|
||||
toggleMute,
|
||||
handleSave,
|
||||
handleSaveACopy,
|
||||
handleSaveSegments,
|
||||
};
|
||||
};
|
||||
|
||||
export default useVideoTrimmer;
|
||||
786
frontend-tools/video-editor/client/src/index.css
Normal file
@ -0,0 +1,786 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
.timeline-thumbnail {
|
||||
height: 100%;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
31
frontend-tools/video-editor/client/src/lib/logger.ts
Normal file
@ -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;
|
||||
57
frontend-tools/video-editor/client/src/lib/queryClient.ts
Normal file
@ -0,0 +1,57 @@
|
||||
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<Response> {
|
||||
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: <T>(options: {
|
||||
on401: UnauthorizedBehavior;
|
||||
}) => QueryFunction<T> =
|
||||
({ 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
34
frontend-tools/video-editor/client/src/lib/timeUtils.ts
Normal file
@ -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);
|
||||
};
|
||||
6
frontend-tools/video-editor/client/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
50
frontend-tools/video-editor/client/src/lib/videoUtils.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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}%)`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy function kept for compatibility
|
||||
* Now returns a data URL for a solid color square instead of a video thumbnail
|
||||
*/
|
||||
export const generateThumbnail = async (
|
||||
videoElement: HTMLVideoElement,
|
||||
time: number
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
// Create a small canvas for the solid color
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 10; // Much smaller - we only need a color
|
||||
canvas.height = 10;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
// Get the solid color based on time
|
||||
const color = generateSolidColor(time, videoElement.duration);
|
||||
|
||||
// Fill with solid color
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// Convert to data URL (much smaller now)
|
||||
const dataUrl = canvas.toDataURL('image/png', 0.5);
|
||||
resolve(dataUrl);
|
||||
});
|
||||
};
|
||||
35
frontend-tools/video-editor/client/src/main.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.MEDIA_DATA = {
|
||||
videoUrl: "",
|
||||
mediaId: ""
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
MEDIA_DATA: {
|
||||
videoUrl: string;
|
||||
mediaId: string;
|
||||
};
|
||||
seekToFunction?: (time: number) => void;
|
||||
}
|
||||
}
|
||||
|
||||
// Mount the components when the DOM is ready
|
||||
const mountComponents = () => {
|
||||
const trimContainer = document.getElementById("video-editor-trim-root");
|
||||
if (trimContainer) {
|
||||
const trimRoot = createRoot(trimContainer);
|
||||
trimRoot.render(<App />);
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mountComponents);
|
||||
} else {
|
||||
mountComponents();
|
||||
}
|
||||
118
frontend-tools/video-editor/client/src/services/videoApi.ts
Normal file
@ -0,0 +1,118 @@
|
||||
// API service for video trimming operations
|
||||
|
||||
interface TrimVideoRequest {
|
||||
segments: {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
name?: string;
|
||||
}[];
|
||||
saveAsCopy?: boolean;
|
||||
saveIndividualSegments?: boolean;
|
||||
}
|
||||
|
||||
interface TrimVideoResponse {
|
||||
msg: string;
|
||||
url_redirect: string;
|
||||
status?: number; // HTTP status code for success/error
|
||||
error?: string; // Error message if status is not 200
|
||||
}
|
||||
|
||||
// Helper function to simulate delay
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// For now, we'll use a mock API that returns a promise
|
||||
// This can be replaced with actual API calls later
|
||||
export const trimVideo = async (
|
||||
mediaId: string,
|
||||
data: TrimVideoRequest
|
||||
): Promise<TrimVideoResponse> => {
|
||||
try {
|
||||
// Attempt the real API call
|
||||
const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// For error responses, return with error status and message
|
||||
if (response.status === 400) {
|
||||
// Handle 400 Bad Request - return with error details
|
||||
try {
|
||||
// Try to get error details from response
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
status: 400,
|
||||
error: errorData.error || "An error occurred during processing",
|
||||
msg: "Video Processing Error",
|
||||
url_redirect: ""
|
||||
};
|
||||
} catch (parseError) {
|
||||
// If can't parse response JSON, return generic error
|
||||
return {
|
||||
status: 400,
|
||||
error: "An error occurred during video processing",
|
||||
msg: "Video Processing Error",
|
||||
url_redirect: ""
|
||||
};
|
||||
}
|
||||
} else if (response.status !== 404) {
|
||||
// Handle other error responses
|
||||
try {
|
||||
// Try to get error details from response
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
status: response.status,
|
||||
error: errorData.error || "An error occurred during processing",
|
||||
msg: "Video Processing Error",
|
||||
url_redirect: ""
|
||||
};
|
||||
} catch (parseError) {
|
||||
// If can't parse response JSON, return generic error
|
||||
return {
|
||||
status: response.status,
|
||||
error: "An error occurred during video processing",
|
||||
msg: "Video Processing Error",
|
||||
url_redirect: ""
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// If endpoint not ready (404), return mock success response
|
||||
await delay(1500); // Simulate 1.5 second server delay
|
||||
return {
|
||||
status: 200, // Mock success status
|
||||
msg: "Video Processed Successfully", // Updated per requirements
|
||||
url_redirect: `./view?m=${mediaId}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Successful response
|
||||
const jsonResponse = await response.json();
|
||||
return {
|
||||
status: 200,
|
||||
msg: "Video Processed Successfully", // Ensure the success message is correct
|
||||
url_redirect: jsonResponse.url_redirect || `./view?m=${mediaId}`,
|
||||
...jsonResponse
|
||||
};
|
||||
} catch (error) {
|
||||
// For any fetch errors, return mock success response with delay
|
||||
await delay(1500); // Simulate 1.5 second server delay
|
||||
return {
|
||||
status: 200, // Mock success status
|
||||
msg: "Video Processed Successfully", // Consistent with requirements
|
||||
url_redirect: `./view?m=${mediaId}`
|
||||
};
|
||||
}
|
||||
|
||||
/* Mock implementation that simulates network latency
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
msg: "Video is processing for trim",
|
||||
url_redirect: `./view?m=${mediaId}`
|
||||
});
|
||||
}, 1500); // Simulate 1.5 second server delay
|
||||
});
|
||||
*/
|
||||
};
|
||||
174
frontend-tools/video-editor/client/src/styles/ClipSegments.css
Normal file
@ -0,0 +1,174 @@
|
||||
#video-editor-trim-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-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground, #333);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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: box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.segment-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.segment-thumbnail {
|
||||
width: 4rem;
|
||||
height: 2.25rem;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 0.25rem;
|
||||
margin-right: 0.75rem;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.segment-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.segment-title {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.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); }
|
||||
}
|
||||
317
frontend-tools/video-editor/client/src/styles/EditingTools.css
Normal file
@ -0,0 +1,317 @@
|
||||
#video-editor-trim-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: 1rem;
|
||||
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;
|
||||
|
||||
/* Disabled hover effect as requested */
|
||||
&: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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Style for the preview mode message that replaces the play button */
|
||||
.preview-mode-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;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-mode-message svg {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
margin-right: 0.5rem;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Hide reset text */
|
||||
.reset-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Ensure buttons stay in correct position */
|
||||
.button-group.play-buttons-group {
|
||||
flex: initial;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.button-group.secondary {
|
||||
flex: initial;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Smaller preview mode message */
|
||||
.preview-mode-message {
|
||||
font-size: 0.8rem;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens - maintain layout but reduce further */
|
||||
@media (max-width: 480px) {
|
||||
.button-group.play-buttons-group,
|
||||
.button-group.secondary {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: none; /* Hide divider on very small screens */
|
||||
}
|
||||
}
|
||||
}
|
||||
223
frontend-tools/video-editor/client/src/styles/Modal.css
Normal file
@ -0,0 +1,223 @@
|
||||
#video-editor-trim-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;
|
||||
}
|
||||
|
||||
.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-error-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: #dc3545;
|
||||
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 0.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;
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,583 @@
|
||||
#video-editor-trim-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;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: red;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-marker-head {
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: red;
|
||||
border-radius: 50%;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
z-index: 31;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.timeline-marker-head-icon {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.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: -90px !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;
|
||||
}
|
||||
|
||||
.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.50rem;
|
||||
}
|
||||
|
||||
.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: rgba(9, 59, 109, 0.9);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
259
frontend-tools/video-editor/client/src/styles/TwoRowTooltip.css
Normal file
@ -0,0 +1,259 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.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 */
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
}
|
||||
}
|
||||
219
frontend-tools/video-editor/client/src/styles/VideoPlayer.css
Normal file
@ -0,0 +1,219 @@
|
||||
#video-editor-trim-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;
|
||||
background-color: black;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
|
||||
.video-player-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
&.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;
|
||||
}
|
||||
|
||||
&.pause-icon::before {
|
||||
width: 20px;
|
||||
height: 25px;
|
||||
border-left: 6px solid white;
|
||||
border-right: 6px solid white;
|
||||
}
|
||||
}
|
||||
|
||||
.video-player-container:hover .play-pause-indicator {
|
||||
opacity: 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 {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&:hover {
|
||||
height: 6px;
|
||||
|
||||
.video-scrubber {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-progress-fill {
|
||||
height: 100%;
|
||||
background-color: #ef4444;
|
||||
border-radius: 2px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.video-scrubber {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #ef4444;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0; /* This will be overridden by inline style */
|
||||
/* Fix vertical centering by adjusting transform */
|
||||
transform: translate(-50%, -50%);
|
||||
transition: transform 0.2s;
|
||||
/* Add a small border to make it more visible */
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
frontend-tools/video-editor/components.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
14
frontend-tools/video-editor/drizzle.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL, ensure the database is provisioned");
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
out: "./migrations",
|
||||
schema: "./shared/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
});
|
||||
BIN
frontend-tools/video-editor/generated-icon.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
52
frontend-tools/video-editor/package.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "video-trim-js",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "tsx server/index.ts",
|
||||
"dev:frontend": "vite",
|
||||
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"check": "tsc",
|
||||
"db:push": "drizzle-kit push",
|
||||
"build:django": "vite build --config vite.video-editor.config.ts --outDir ../../../static/video_editor"
|
||||
},
|
||||
"dependencies": {
|
||||
"@neondatabase/serverless": "^0.10.4",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.39.1",
|
||||
"drizzle-zod": "^0.7.0",
|
||||
"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",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"esbuild": "^0.25.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^5.4.18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.8"
|
||||
}
|
||||
}
|
||||
6
frontend-tools/video-editor/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
70
frontend-tools/video-editor/server/index.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import express, { type Request, Response, NextFunction } from "express";
|
||||
import { registerRoutes } from "./routes";
|
||||
import { setupVite, serveStatic, log } from "./vite";
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
const path = req.path;
|
||||
let capturedJsonResponse: Record<string, any> | undefined = undefined;
|
||||
|
||||
const originalResJson = res.json;
|
||||
res.json = function (bodyJson, ...args) {
|
||||
capturedJsonResponse = bodyJson;
|
||||
return originalResJson.apply(res, [bodyJson, ...args]);
|
||||
};
|
||||
|
||||
res.on("finish", () => {
|
||||
const duration = Date.now() - start;
|
||||
if (path.startsWith("/api")) {
|
||||
let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
|
||||
if (capturedJsonResponse) {
|
||||
logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
|
||||
}
|
||||
|
||||
if (logLine.length > 80) {
|
||||
logLine = logLine.slice(0, 79) + "…";
|
||||
}
|
||||
|
||||
log(logLine);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const server = await registerRoutes(app);
|
||||
|
||||
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||
const status = err.status || err.statusCode || 500;
|
||||
const message = err.message || "Internal Server Error";
|
||||
|
||||
res.status(status).json({ message });
|
||||
throw err;
|
||||
});
|
||||
|
||||
// importantly only setup vite in development and after
|
||||
// setting up all the other routes so the catch-all route
|
||||
// doesn't interfere with the other routes
|
||||
if (app.get("env") === "development") {
|
||||
await setupVite(app, server);
|
||||
} else {
|
||||
serveStatic(app);
|
||||
}
|
||||
|
||||
// ALWAYS serve the app on port 5000
|
||||
// this serves both the API and the client.
|
||||
// It is the only port that is not firewalled.
|
||||
const port = 5000;
|
||||
server.listen({
|
||||
port,
|
||||
host: "0.0.0.0",
|
||||
reusePort: true,
|
||||
}, () => {
|
||||
log(`serving on port ${port}`);
|
||||
});
|
||||
})();
|
||||
15
frontend-tools/video-editor/server/routes.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { Express } from "express";
|
||||
import { createServer, type Server } from "http";
|
||||
import { storage } from "./storage";
|
||||
|
||||
export async function registerRoutes(app: Express): Promise<Server> {
|
||||
// put application routes here
|
||||
// prefix all routes with /api
|
||||
|
||||
// use storage to perform CRUD operations on the storage interface
|
||||
// e.g. storage.insertUser(user) or storage.getUserByUsername(username)
|
||||
|
||||
const httpServer = createServer(app);
|
||||
|
||||
return httpServer;
|
||||
}
|
||||
39
frontend-tools/video-editor/server/storage.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { users, type User, type InsertUser } from "@shared/schema";
|
||||
|
||||
// modify the interface with any CRUD methods
|
||||
// you might need
|
||||
|
||||
export interface IStorage {
|
||||
getUser(id: number): Promise<User | undefined>;
|
||||
getUserByUsername(username: string): Promise<User | undefined>;
|
||||
createUser(user: InsertUser): Promise<User>;
|
||||
}
|
||||
|
||||
export class MemStorage implements IStorage {
|
||||
private users: Map<number, User>;
|
||||
currentId: number;
|
||||
|
||||
constructor() {
|
||||
this.users = new Map();
|
||||
this.currentId = 1;
|
||||
}
|
||||
|
||||
async getUser(id: number): Promise<User | undefined> {
|
||||
return this.users.get(id);
|
||||
}
|
||||
|
||||
async getUserByUsername(username: string): Promise<User | undefined> {
|
||||
return Array.from(this.users.values()).find(
|
||||
(user) => user.username === username,
|
||||
);
|
||||
}
|
||||
|
||||
async createUser(insertUser: InsertUser): Promise<User> {
|
||||
const id = this.currentId++;
|
||||
const user: User = { ...insertUser, id };
|
||||
this.users.set(id, user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
export const storage = new MemStorage();
|
||||
85
frontend-tools/video-editor/server/vite.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import express, { type Express } from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { createServer as createViteServer, createLogger } from "vite";
|
||||
import { type Server } from "http";
|
||||
import viteConfig from "../vite.config";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const viteLogger = createLogger();
|
||||
|
||||
export function log(message: string, source = "express") {
|
||||
const formattedTime = new Date().toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
console.log(`${formattedTime} [${source}] ${message}`);
|
||||
}
|
||||
|
||||
export async function setupVite(app: Express, server: Server) {
|
||||
const serverOptions = {
|
||||
middlewareMode: true,
|
||||
hmr: { server },
|
||||
allowedHosts: true,
|
||||
};
|
||||
|
||||
const vite = await createViteServer({
|
||||
...viteConfig,
|
||||
configFile: false,
|
||||
customLogger: {
|
||||
...viteLogger,
|
||||
error: (msg, options) => {
|
||||
viteLogger.error(msg, options);
|
||||
process.exit(1);
|
||||
},
|
||||
},
|
||||
server: serverOptions,
|
||||
appType: "custom",
|
||||
});
|
||||
|
||||
app.use(vite.middlewares);
|
||||
app.use("*", async (req, res, next) => {
|
||||
const url = req.originalUrl;
|
||||
|
||||
try {
|
||||
const clientTemplate = path.resolve(
|
||||
import.meta.dirname,
|
||||
"..",
|
||||
"client",
|
||||
"index.html",
|
||||
);
|
||||
|
||||
// always reload the index.html file from disk incase it changes
|
||||
let template = await fs.promises.readFile(clientTemplate, "utf-8");
|
||||
template = template.replace(
|
||||
`src="/src/main.tsx"`,
|
||||
`src="/src/main.tsx?v=${nanoid()}"`,
|
||||
);
|
||||
const page = await vite.transformIndexHtml(url, template);
|
||||
res.status(200).set({ "Content-Type": "text/html" }).end(page);
|
||||
} catch (e) {
|
||||
vite.ssrFixStacktrace(e as Error);
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function serveStatic(app: Express) {
|
||||
const distPath = path.resolve(import.meta.dirname, "public");
|
||||
|
||||
if (!fs.existsSync(distPath)) {
|
||||
throw new Error(
|
||||
`Could not find the build directory: ${distPath}, make sure to build the client first`,
|
||||
);
|
||||
}
|
||||
|
||||
app.use(express.static(distPath));
|
||||
|
||||
// fall through to index.html if the file doesn't exist
|
||||
app.use("*", (_req, res) => {
|
||||
res.sendFile(path.resolve(distPath, "index.html"));
|
||||
});
|
||||
}
|
||||
17
frontend-tools/video-editor/shared/schema.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { pgTable, text, serial, integer, boolean } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { z } from "zod";
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: serial("id").primaryKey(),
|
||||
username: text("username").notNull().unique(),
|
||||
password: text("password").notNull(),
|
||||
});
|
||||
|
||||
export const insertUserSchema = createInsertSchema(users).pick({
|
||||
username: true,
|
||||
password: true,
|
||||
});
|
||||
|
||||
export type InsertUser = z.infer<typeof insertUserSchema>;
|
||||
export type User = typeof users.$inferSelect;
|
||||
90
frontend-tools/video-editor/tailwind.config.ts
Normal file
@ -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;
|
||||
23
frontend-tools/video-editor/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"include": ["client/src/**/*", "shared/**/*", "server/**/*"],
|
||||
"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/*"],
|
||||
"@shared/*": ["./shared/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
20
frontend-tools/video-editor/vite.config.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(import.meta.dirname, "client", "src"),
|
||||
"@shared": path.resolve(import.meta.dirname, "shared"),
|
||||
},
|
||||
},
|
||||
root: path.resolve(import.meta.dirname, "client"),
|
||||
build: {
|
||||
outDir: path.resolve(import.meta.dirname, "dist/public"),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
53
frontend-tools/video-editor/vite.video-editor.config.ts
Normal file
@ -0,0 +1,53 @@
|
||||
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"),
|
||||
"@shared": path.resolve(__dirname, "shared"),
|
||||
},
|
||||
},
|
||||
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: 'VideoEditor',
|
||||
formats: ['iife'],
|
||||
fileName: () => 'video-editor.js',
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Ensure CSS file has a predictable name
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (assetInfo.name === 'style.css') return 'video-editor.css';
|
||||
return assetInfo.name;
|
||||
},
|
||||
// Add this to ensure the final bundle exposes React correctly
|
||||
globals: {
|
||||
'react': 'React',
|
||||
'react-dom': 'ReactDOM'
|
||||
}
|
||||
},
|
||||
},
|
||||
// Output to Django's static directory
|
||||
outDir: '../../../static/video_editor',
|
||||
emptyOutDir: true,
|
||||
external: ['react', 'react-dom']
|
||||
},
|
||||
});
|
||||
2804
frontend-tools/video-editor/yarn.lock
Normal file
293
frontend/package-lock.json
generated
@ -120,14 +120,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz",
|
||||
"integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz",
|
||||
"integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.26.10",
|
||||
"@babel/types": "^7.26.10",
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/types": "^7.27.0",
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"jsesc": "^3.0.2"
|
||||
@ -150,13 +150,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.26.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz",
|
||||
"integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz",
|
||||
"integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.26.5",
|
||||
"@babel/compat-data": "^7.26.8",
|
||||
"@babel/helper-validator-option": "^7.25.9",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
@ -167,9 +167,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-create-class-features-plugin": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz",
|
||||
"integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz",
|
||||
"integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -178,7 +178,7 @@
|
||||
"@babel/helper-optimise-call-expression": "^7.25.9",
|
||||
"@babel/helper-replace-supers": "^7.26.5",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
|
||||
"@babel/traverse": "^7.26.9",
|
||||
"@babel/traverse": "^7.27.0",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@ -189,9 +189,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-create-regexp-features-plugin": {
|
||||
"version": "7.26.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz",
|
||||
"integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.0.tgz",
|
||||
"integrity": "sha512-fO8l08T76v48BhpNRW/nQ0MxfnSdoSKUJBMjubOAYffsVuGG5qOfMq7N6Es7UJvi7Y8goXXo07EfcHZXDPuELQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -207,9 +207,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-define-polyfill-provider": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz",
|
||||
"integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==",
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz",
|
||||
"integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -388,27 +388,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
|
||||
"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
|
||||
"integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.26.9",
|
||||
"@babel/types": "^7.26.10"
|
||||
"@babel/template": "^7.27.0",
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz",
|
||||
"integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
|
||||
"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.10"
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@ -716,13 +716,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-block-scoping": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz",
|
||||
"integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.0.tgz",
|
||||
"integrity": "sha512-u1jGphZ8uDI2Pj/HJj6YQ6XQLZCNjOlprjxB5SVz6rq2T6SwAR+CdrWK0CP7F+9rDVMXdB0+r6Am5G5aobOjAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.25.9"
|
||||
"@babel/helper-plugin-utils": "^7.26.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@ -1372,13 +1372,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-regenerator": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz",
|
||||
"integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.0.tgz",
|
||||
"integrity": "sha512-LX/vCajUJQDqE7Aum/ELUMZAY19+cDpghxrnyt5I1tV6X5PyC86AOoWXWFYFeIvauyeSA6/ktn4tQVn/3ZifsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.25.9",
|
||||
"@babel/helper-plugin-utils": "^7.26.5",
|
||||
"regenerator-transform": "^0.15.2"
|
||||
},
|
||||
"engines": {
|
||||
@ -1487,9 +1487,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-typeof-symbol": {
|
||||
"version": "7.26.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.26.7.tgz",
|
||||
"integrity": "sha512-jfoTXXZTgGg36BmhqT3cAYK5qkmqvJpvNrPhaK/52Vgjhw4Rq29s9UqpWWV0D6yuRmgiFH/BUVlkl96zJWqnaw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.0.tgz",
|
||||
"integrity": "sha512-+LLkxA9rKJpNoGsbLnAgOCdESl73vwYn+V6b+5wHbrE7OGKVDPHIQvbFSzqE6rwqaCw2RE+zdJrlLkcf8YOA0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -1503,14 +1503,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-typescript": {
|
||||
"version": "7.26.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.26.8.tgz",
|
||||
"integrity": "sha512-bME5J9AC8ChwA7aEPJ6zym3w7aObZULHhbNLU0bKUhKsAkylkzUdq+0kdymh9rzi8nlNFl2bmldFBCKNJBUpuw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.0.tgz",
|
||||
"integrity": "sha512-fRGGjO2UEGPjvEcyAZXRXAS8AfdaQoq7HnxAbJoAoW10B9xOKesmmndJv+Sym2a+9FHWZ9KbyyLCe9s0Sn5jtg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-annotate-as-pure": "^7.25.9",
|
||||
"@babel/helper-create-class-features-plugin": "^7.25.9",
|
||||
"@babel/helper-create-class-features-plugin": "^7.27.0",
|
||||
"@babel/helper-plugin-utils": "^7.26.5",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
|
||||
"@babel/plugin-syntax-typescript": "^7.25.9"
|
||||
@ -1710,17 +1710,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/preset-typescript": {
|
||||
"version": "7.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.26.0.tgz",
|
||||
"integrity": "sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.0.tgz",
|
||||
"integrity": "sha512-vxaPFfJtHhgeOVXRKuHpHPAOgymmy8V8I65T1q53R7GCZlefKeCaTyDs3zOPHTTbmquvNlQYC5klEvWsBAtrBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.25.9",
|
||||
"@babel/helper-plugin-utils": "^7.26.5",
|
||||
"@babel/helper-validator-option": "^7.25.9",
|
||||
"@babel/plugin-syntax-jsx": "^7.25.9",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.25.9",
|
||||
"@babel/plugin-transform-typescript": "^7.25.9"
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.26.3",
|
||||
"@babel/plugin-transform-typescript": "^7.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@ -1914,32 +1914,32 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
|
||||
"integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
|
||||
"integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/parser": "^7.26.9",
|
||||
"@babel/types": "^7.26.9"
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.10.tgz",
|
||||
"integrity": "sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz",
|
||||
"integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/generator": "^7.26.10",
|
||||
"@babel/parser": "^7.26.10",
|
||||
"@babel/template": "^7.26.9",
|
||||
"@babel/types": "^7.26.10",
|
||||
"@babel/generator": "^7.27.0",
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/template": "^7.27.0",
|
||||
"@babel/types": "^7.27.0",
|
||||
"debug": "^4.3.1",
|
||||
"globals": "^11.1.0"
|
||||
},
|
||||
@ -1948,9 +1948,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz",
|
||||
"integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
|
||||
"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -2432,9 +2432,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.28",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz",
|
||||
"integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==",
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -3098,15 +3098,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz",
|
||||
"integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz",
|
||||
"integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/qs": "*",
|
||||
"@types/serve-static": "*"
|
||||
}
|
||||
},
|
||||
@ -3180,13 +3179,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
|
||||
"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
|
||||
"version": "22.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
|
||||
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.20.0"
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
@ -3225,9 +3224,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
|
||||
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
|
||||
"version": "19.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz",
|
||||
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -3235,9 +3234,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz",
|
||||
"integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==",
|
||||
"version": "19.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
|
||||
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
@ -3921,19 +3920,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/array.prototype.reduce": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.7.tgz",
|
||||
"integrity": "sha512-mzmiUCVwtiD4lgxYP8g7IYy8El8p2CSMePvIbTS7gchKir/L1fgJrk0yDKmAX6mnRQFKNADYIk8nNlTris5H1Q==",
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.8.tgz",
|
||||
"integrity": "sha512-DwuEqgXFBwbmZSRqt3BpQigWNUoqw9Ml2dTWdF3B2zQlQX4OeUE0zyuzX0fX0IbTvjdkZbcBTU3idgpO78qkTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.7",
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.4",
|
||||
"define-properties": "^1.2.1",
|
||||
"es-abstract": "^1.23.2",
|
||||
"es-abstract": "^1.23.9",
|
||||
"es-array-method-boxes-properly": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.0.0",
|
||||
"is-string": "^1.0.7"
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"is-string": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@ -4171,9 +4171,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
|
||||
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
|
||||
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
@ -4476,14 +4476,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||
"version": "0.4.12",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz",
|
||||
"integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==",
|
||||
"version": "0.4.13",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz",
|
||||
"integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.22.6",
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.3",
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.4",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@ -4505,13 +4505,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-regenerator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz",
|
||||
"integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==",
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz",
|
||||
"integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.3"
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||
@ -5828,9 +5828,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001703",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz",
|
||||
"integrity": "sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ==",
|
||||
"version": "1.0.30001713",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz",
|
||||
"integrity": "sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -7454,9 +7454,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
||||
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
@ -7558,9 +7558,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.115",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.115.tgz",
|
||||
"integrity": "sha512-MN1nahVHAQMOz6dz6bNZ7apgqc9InZy7Ja4DBEVCTdeiUcegbyOYE9bi/f2Z/z6ZxLi0RxLpyJ3EGe+4h3w73A==",
|
||||
"version": "1.5.137",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.137.tgz",
|
||||
"integrity": "sha512-/QSJaU2JyIuTbbABAo/crOs+SuAZLS+fVVS10PVrIT9hrRkmZl8Hb0xPSkKRUUWHQtYzXHpQUW3Dy5hwMzGZkA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@ -10317,9 +10317,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-parser-js": {
|
||||
"version": "0.5.9",
|
||||
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.9.tgz",
|
||||
"integrity": "sha512-n1XsPy3rXVxlqxVioEWdC+0+M+SQw0DpJynwtOPo1X+ZlvdzTLtDBIJJlDQTnwZIFJrZSzSGmIOUdP8tu+SgLw==",
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
|
||||
"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -10457,9 +10457,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
|
||||
"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz",
|
||||
"integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -12585,9 +12585,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.9",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.9.tgz",
|
||||
"integrity": "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -13963,9 +13963,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pirates": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
|
||||
"integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -14131,18 +14131,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/portfinder": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.34.tgz",
|
||||
"integrity": "sha512-aTyliYB9lpGRx0iUzgbLpCz3xiCEBsPOiukSp1X8fOnHalHCKNxxpv2cSc00Cc49mpWUtlyRVFHRSaRJF8ew3g==",
|
||||
"version": "1.0.36",
|
||||
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.36.tgz",
|
||||
"integrity": "sha512-gMKUzCoP+feA7t45moaSx7UniU7PgGN3hA8acAB+3Qn7/js0/lJ07fYZlxt9riE9S3myyxDCyAFzSrLlta0c9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"async": "^3.2.6",
|
||||
"debug": "^4.3.6",
|
||||
"mkdirp": "^0.5.6"
|
||||
"debug": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0"
|
||||
"node": ">= 10.12"
|
||||
}
|
||||
},
|
||||
"node_modules/posix-character-classes": {
|
||||
@ -15563,9 +15562,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-transform/node_modules/@babel/runtime": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
|
||||
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -16465,9 +16464,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.85.1",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz",
|
||||
"integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==",
|
||||
"version": "1.86.3",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.86.3.tgz",
|
||||
"integrity": "sha512-iGtg8kus4GrsGLRDLRBRHY9dNVA78ZaS7xr01cWnS7PEMQyFtTqBiyCrfpTYTZXRWM94akzckYjh8oADfFNTzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -18807,9 +18806,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
|
||||
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@ -18917,9 +18916,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -19483,9 +19482,9 @@
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.98.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz",
|
||||
"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
|
||||
"version": "5.99.5",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.5.tgz",
|
||||
"integrity": "sha512-q+vHBa6H9qwBLUlHL4Y7L0L1/LlyBKZtS9FHNCQmtayxjI5RKC9yD8gpvLeqGv5lCQp1Re04yi0MF40pf30Pvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -20371,9 +20370,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webpack/node_modules/@types/estree": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -21051,9 +21050,9 @@
|
||||
}
|
||||
},
|
||||
"packages/scripts/node_modules/@types/react": {
|
||||
"version": "17.0.83",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.83.tgz",
|
||||
"integrity": "sha512-l0m4ArKJvmFtR4e8UmKrj1pB4tUgOhJITf+mADyF/p69Ts1YAR/E+G9XEM0mHXKVRa1dQNHseyyDNzeuAXfXQw==",
|
||||
"version": "17.0.85",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.85.tgz",
|
||||
"integrity": "sha512-5oBDUsRDsrYq4DdyHaL99gE1AJCfuDhyxqF6/55fvvOIRkp1PpKuwJ+aMiGJR+GJt7YqMNclPROTHF20vY2cXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
174
frontend/packages/player/package-lock.json
generated
@ -159,14 +159,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz",
|
||||
"integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz",
|
||||
"integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.26.10",
|
||||
"@babel/types": "^7.26.10",
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/types": "^7.27.0",
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"jsesc": "^3.0.2"
|
||||
@ -189,13 +189,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.26.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz",
|
||||
"integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz",
|
||||
"integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.26.5",
|
||||
"@babel/compat-data": "^7.26.8",
|
||||
"@babel/helper-validator-option": "^7.25.9",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
@ -216,9 +216,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-create-class-features-plugin": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz",
|
||||
"integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz",
|
||||
"integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -227,7 +227,7 @@
|
||||
"@babel/helper-optimise-call-expression": "^7.25.9",
|
||||
"@babel/helper-replace-supers": "^7.26.5",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
|
||||
"@babel/traverse": "^7.26.9",
|
||||
"@babel/traverse": "^7.27.0",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@ -248,9 +248,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-create-regexp-features-plugin": {
|
||||
"version": "7.26.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz",
|
||||
"integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.0.tgz",
|
||||
"integrity": "sha512-fO8l08T76v48BhpNRW/nQ0MxfnSdoSKUJBMjubOAYffsVuGG5qOfMq7N6Es7UJvi7Y8goXXo07EfcHZXDPuELQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -276,9 +276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-define-polyfill-provider": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz",
|
||||
"integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==",
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz",
|
||||
"integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -457,27 +457,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
|
||||
"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
|
||||
"integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.26.9",
|
||||
"@babel/types": "^7.26.10"
|
||||
"@babel/template": "^7.27.0",
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz",
|
||||
"integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
|
||||
"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.10"
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@ -701,13 +701,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-block-scoping": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz",
|
||||
"integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.0.tgz",
|
||||
"integrity": "sha512-u1jGphZ8uDI2Pj/HJj6YQ6XQLZCNjOlprjxB5SVz6rq2T6SwAR+CdrWK0CP7F+9rDVMXdB0+r6Am5G5aobOjAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.25.9"
|
||||
"@babel/helper-plugin-utils": "^7.26.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@ -1272,13 +1272,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-regenerator": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz",
|
||||
"integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.0.tgz",
|
||||
"integrity": "sha512-LX/vCajUJQDqE7Aum/ELUMZAY19+cDpghxrnyt5I1tV6X5PyC86AOoWXWFYFeIvauyeSA6/ktn4tQVn/3ZifsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.25.9",
|
||||
"@babel/helper-plugin-utils": "^7.26.5",
|
||||
"regenerator-transform": "^0.15.2"
|
||||
},
|
||||
"engines": {
|
||||
@ -1387,9 +1387,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-typeof-symbol": {
|
||||
"version": "7.26.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.26.7.tgz",
|
||||
"integrity": "sha512-jfoTXXZTgGg36BmhqT3cAYK5qkmqvJpvNrPhaK/52Vgjhw4Rq29s9UqpWWV0D6yuRmgiFH/BUVlkl96zJWqnaw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.0.tgz",
|
||||
"integrity": "sha512-+LLkxA9rKJpNoGsbLnAgOCdESl73vwYn+V6b+5wHbrE7OGKVDPHIQvbFSzqE6rwqaCw2RE+zdJrlLkcf8YOA0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -1579,9 +1579,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
|
||||
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -1599,32 +1599,32 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
|
||||
"integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
|
||||
"integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/parser": "^7.26.9",
|
||||
"@babel/types": "^7.26.9"
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.10.tgz",
|
||||
"integrity": "sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz",
|
||||
"integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/generator": "^7.26.10",
|
||||
"@babel/parser": "^7.26.10",
|
||||
"@babel/template": "^7.26.9",
|
||||
"@babel/types": "^7.26.10",
|
||||
"@babel/generator": "^7.27.0",
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/template": "^7.27.0",
|
||||
"@babel/types": "^7.27.0",
|
||||
"debug": "^4.3.1",
|
||||
"globals": "^11.1.0"
|
||||
},
|
||||
@ -1633,9 +1633,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz",
|
||||
"integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
|
||||
"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -2115,13 +2115,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
|
||||
"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
|
||||
"version": "22.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
|
||||
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.20.0"
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
@ -2482,14 +2482,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||
"version": "0.4.12",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz",
|
||||
"integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==",
|
||||
"version": "0.4.13",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz",
|
||||
"integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.22.6",
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.3",
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.4",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@ -2521,13 +2521,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-regenerator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz",
|
||||
"integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==",
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz",
|
||||
"integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.3"
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||
@ -3235,9 +3235,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001703",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz",
|
||||
"integrity": "sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ==",
|
||||
"version": "1.0.30001713",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz",
|
||||
"integrity": "sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -3792,9 +3792,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.115",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.115.tgz",
|
||||
"integrity": "sha512-MN1nahVHAQMOz6dz6bNZ7apgqc9InZy7Ja4DBEVCTdeiUcegbyOYE9bi/f2Z/z6ZxLi0RxLpyJ3EGe+4h3w73A==",
|
||||
"version": "1.5.137",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.137.tgz",
|
||||
"integrity": "sha512-/QSJaU2JyIuTbbABAo/crOs+SuAZLS+fVVS10PVrIT9hrRkmZl8Hb0xPSkKRUUWHQtYzXHpQUW3Dy5hwMzGZkA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@ -4132,9 +4132,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
|
||||
"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz",
|
||||
"integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -4611,9 +4611,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.9",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.9.tgz",
|
||||
"integrity": "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -5956,9 +5956,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.85.1",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz",
|
||||
"integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==",
|
||||
"version": "1.86.3",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.86.3.tgz",
|
||||
"integrity": "sha512-iGtg8kus4GrsGLRDLRBRHY9dNVA78ZaS7xr01cWnS7PEMQyFtTqBiyCrfpTYTZXRWM94akzckYjh8oADfFNTzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -6368,9 +6368,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
253
frontend/packages/scripts/package-lock.json
generated
@ -152,13 +152,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz",
|
||||
"integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz",
|
||||
"integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.26.10",
|
||||
"@babel/types": "^7.26.10",
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/types": "^7.27.0",
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"jsesc": "^3.0.2"
|
||||
@ -180,12 +180,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.26.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz",
|
||||
"integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz",
|
||||
"integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.26.5",
|
||||
"@babel/compat-data": "^7.26.8",
|
||||
"@babel/helper-validator-option": "^7.25.9",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
@ -205,9 +205,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-create-class-features-plugin": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz",
|
||||
"integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz",
|
||||
"integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-annotate-as-pure": "^7.25.9",
|
||||
@ -215,7 +215,7 @@
|
||||
"@babel/helper-optimise-call-expression": "^7.25.9",
|
||||
"@babel/helper-replace-supers": "^7.26.5",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
|
||||
"@babel/traverse": "^7.26.9",
|
||||
"@babel/traverse": "^7.27.0",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@ -235,9 +235,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-create-regexp-features-plugin": {
|
||||
"version": "7.26.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz",
|
||||
"integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.0.tgz",
|
||||
"integrity": "sha512-fO8l08T76v48BhpNRW/nQ0MxfnSdoSKUJBMjubOAYffsVuGG5qOfMq7N6Es7UJvi7Y8goXXo07EfcHZXDPuELQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-annotate-as-pure": "^7.25.9",
|
||||
@ -261,9 +261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-define-polyfill-provider": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz",
|
||||
"integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==",
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz",
|
||||
"integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-compilation-targets": "^7.22.6",
|
||||
@ -429,25 +429,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
|
||||
"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
|
||||
"integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.26.9",
|
||||
"@babel/types": "^7.26.10"
|
||||
"@babel/template": "^7.27.0",
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz",
|
||||
"integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
|
||||
"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.10"
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@ -741,12 +741,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-block-scoping": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz",
|
||||
"integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.0.tgz",
|
||||
"integrity": "sha512-u1jGphZ8uDI2Pj/HJj6YQ6XQLZCNjOlprjxB5SVz6rq2T6SwAR+CdrWK0CP7F+9rDVMXdB0+r6Am5G5aobOjAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.25.9"
|
||||
"@babel/helper-plugin-utils": "^7.26.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@ -1358,12 +1358,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-regenerator": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz",
|
||||
"integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.0.tgz",
|
||||
"integrity": "sha512-LX/vCajUJQDqE7Aum/ELUMZAY19+cDpghxrnyt5I1tV6X5PyC86AOoWXWFYFeIvauyeSA6/ktn4tQVn/3ZifsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.25.9",
|
||||
"@babel/helper-plugin-utils": "^7.26.5",
|
||||
"regenerator-transform": "^0.15.2"
|
||||
},
|
||||
"engines": {
|
||||
@ -1466,9 +1466,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-typeof-symbol": {
|
||||
"version": "7.26.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.26.7.tgz",
|
||||
"integrity": "sha512-jfoTXXZTgGg36BmhqT3cAYK5qkmqvJpvNrPhaK/52Vgjhw4Rq29s9UqpWWV0D6yuRmgiFH/BUVlkl96zJWqnaw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.0.tgz",
|
||||
"integrity": "sha512-+LLkxA9rKJpNoGsbLnAgOCdESl73vwYn+V6b+5wHbrE7OGKVDPHIQvbFSzqE6rwqaCw2RE+zdJrlLkcf8YOA0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.26.5"
|
||||
@ -1481,14 +1481,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-typescript": {
|
||||
"version": "7.26.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.26.8.tgz",
|
||||
"integrity": "sha512-bME5J9AC8ChwA7aEPJ6zym3w7aObZULHhbNLU0bKUhKsAkylkzUdq+0kdymh9rzi8nlNFl2bmldFBCKNJBUpuw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.0.tgz",
|
||||
"integrity": "sha512-fRGGjO2UEGPjvEcyAZXRXAS8AfdaQoq7HnxAbJoAoW10B9xOKesmmndJv+Sym2a+9FHWZ9KbyyLCe9s0Sn5jtg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-annotate-as-pure": "^7.25.9",
|
||||
"@babel/helper-create-class-features-plugin": "^7.25.9",
|
||||
"@babel/helper-create-class-features-plugin": "^7.27.0",
|
||||
"@babel/helper-plugin-utils": "^7.26.5",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
|
||||
"@babel/plugin-syntax-typescript": "^7.25.9"
|
||||
@ -1690,17 +1690,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/preset-typescript": {
|
||||
"version": "7.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.26.0.tgz",
|
||||
"integrity": "sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.0.tgz",
|
||||
"integrity": "sha512-vxaPFfJtHhgeOVXRKuHpHPAOgymmy8V8I65T1q53R7GCZlefKeCaTyDs3zOPHTTbmquvNlQYC5klEvWsBAtrBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.25.9",
|
||||
"@babel/helper-plugin-utils": "^7.26.5",
|
||||
"@babel/helper-validator-option": "^7.25.9",
|
||||
"@babel/plugin-syntax-jsx": "^7.25.9",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.25.9",
|
||||
"@babel/plugin-transform-typescript": "^7.25.9"
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.26.3",
|
||||
"@babel/plugin-transform-typescript": "^7.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@ -1710,9 +1710,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
|
||||
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
@ -1728,30 +1728,30 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
|
||||
"integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
|
||||
"integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/parser": "^7.26.9",
|
||||
"@babel/types": "^7.26.9"
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.10.tgz",
|
||||
"integrity": "sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz",
|
||||
"integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/generator": "^7.26.10",
|
||||
"@babel/parser": "^7.26.10",
|
||||
"@babel/template": "^7.26.9",
|
||||
"@babel/types": "^7.26.10",
|
||||
"@babel/generator": "^7.27.0",
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/template": "^7.27.0",
|
||||
"@babel/types": "^7.27.0",
|
||||
"debug": "^4.3.1",
|
||||
"globals": "^11.1.0"
|
||||
},
|
||||
@ -1760,9 +1760,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz",
|
||||
"integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
|
||||
"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.25.9",
|
||||
@ -2174,9 +2174,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.28",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz",
|
||||
"integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==",
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/plugin-node-resolve": {
|
||||
@ -2528,14 +2528,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz",
|
||||
"integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz",
|
||||
"integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/qs": "*",
|
||||
"@types/serve-static": "*"
|
||||
}
|
||||
},
|
||||
@ -2601,12 +2600,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
|
||||
"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
|
||||
"version": "22.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
|
||||
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.20.0"
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
@ -2641,7 +2640,7 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "17.0.83",
|
||||
"version": "17.0.85",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -3223,18 +3222,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/array.prototype.reduce": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.7.tgz",
|
||||
"integrity": "sha512-mzmiUCVwtiD4lgxYP8g7IYy8El8p2CSMePvIbTS7gchKir/L1fgJrk0yDKmAX6mnRQFKNADYIk8nNlTris5H1Q==",
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.8.tgz",
|
||||
"integrity": "sha512-DwuEqgXFBwbmZSRqt3BpQigWNUoqw9Ml2dTWdF3B2zQlQX4OeUE0zyuzX0fX0IbTvjdkZbcBTU3idgpO78qkTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.7",
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.4",
|
||||
"define-properties": "^1.2.1",
|
||||
"es-abstract": "^1.23.2",
|
||||
"es-abstract": "^1.23.9",
|
||||
"es-array-method-boxes-properly": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.0.0",
|
||||
"is-string": "^1.0.7"
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"is-string": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@ -3565,13 +3565,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||
"version": "0.4.12",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz",
|
||||
"integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==",
|
||||
"version": "0.4.13",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz",
|
||||
"integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.22.6",
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.3",
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.4",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@ -3601,12 +3601,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-regenerator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz",
|
||||
"integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==",
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz",
|
||||
"integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.3"
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||
@ -4280,9 +4280,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001703",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz",
|
||||
"integrity": "sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ==",
|
||||
"version": "1.0.30001713",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz",
|
||||
"integrity": "sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@ -5757,9 +5757,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.115",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.115.tgz",
|
||||
"integrity": "sha512-MN1nahVHAQMOz6dz6bNZ7apgqc9InZy7Ja4DBEVCTdeiUcegbyOYE9bi/f2Z/z6ZxLi0RxLpyJ3EGe+4h3w73A==",
|
||||
"version": "1.5.137",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.137.tgz",
|
||||
"integrity": "sha512-/QSJaU2JyIuTbbABAo/crOs+SuAZLS+fVVS10PVrIT9hrRkmZl8Hb0xPSkKRUUWHQtYzXHpQUW3Dy5hwMzGZkA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/elliptic": {
|
||||
@ -7502,9 +7502,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-parser-js": {
|
||||
"version": "0.5.9",
|
||||
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.9.tgz",
|
||||
"integrity": "sha512-n1XsPy3rXVxlqxVioEWdC+0+M+SQw0DpJynwtOPo1X+ZlvdzTLtDBIJJlDQTnwZIFJrZSzSGmIOUdP8tu+SgLw==",
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
|
||||
"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-proxy": {
|
||||
@ -7605,9 +7605,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
|
||||
"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz",
|
||||
"integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
@ -9182,9 +9182,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.9",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.9.tgz",
|
||||
"integrity": "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -10045,17 +10045,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/portfinder": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.34.tgz",
|
||||
"integrity": "sha512-aTyliYB9lpGRx0iUzgbLpCz3xiCEBsPOiukSp1X8fOnHalHCKNxxpv2cSc00Cc49mpWUtlyRVFHRSaRJF8ew3g==",
|
||||
"version": "1.0.36",
|
||||
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.36.tgz",
|
||||
"integrity": "sha512-gMKUzCoP+feA7t45moaSx7UniU7PgGN3hA8acAB+3Qn7/js0/lJ07fYZlxt9riE9S3myyxDCyAFzSrLlta0c9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"async": "^3.2.6",
|
||||
"debug": "^4.3.6",
|
||||
"mkdirp": "^0.5.6"
|
||||
"debug": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0"
|
||||
"node": ">= 10.12"
|
||||
}
|
||||
},
|
||||
"node_modules/posix-character-classes": {
|
||||
@ -11735,9 +11734,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.85.1",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz",
|
||||
"integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==",
|
||||
"version": "1.86.3",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.86.3.tgz",
|
||||
"integrity": "sha512-iGtg8kus4GrsGLRDLRBRHY9dNVA78ZaS7xr01cWnS7PEMQyFtTqBiyCrfpTYTZXRWM94akzckYjh8oADfFNTzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
@ -13482,9 +13481,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
@ -13862,9 +13861,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.98.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz",
|
||||
"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
|
||||
"version": "5.99.5",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.5.tgz",
|
||||
"integrity": "sha512-q+vHBa6H9qwBLUlHL4Y7L0L1/LlyBKZtS9FHNCQmtayxjI5RKC9yD8gpvLeqGv5lCQp1Re04yi0MF40pf30Pvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
@ -14652,9 +14651,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webpack/node_modules/@types/estree": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webpack/node_modules/ajv": {
|
||||
|
||||
@ -15,8 +15,10 @@ export function PopupContent(props) {
|
||||
}
|
||||
|
||||
const domElem = findDOMNode(wrapperRef.current);
|
||||
const clickedElement = ev.target;
|
||||
|
||||
if (-1 === ev.path.indexOf(domElem)) {
|
||||
// Check if the clicked element is outside the popup
|
||||
if (domElem && !domElem.contains(clickedElement)) {
|
||||
hide();
|
||||
}
|
||||
}, []);
|
||||
@ -29,12 +31,12 @@ export function PopupContent(props) {
|
||||
}, []);
|
||||
|
||||
function enableListeners() {
|
||||
document.addEventListener('click', onClickOutside);
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
}
|
||||
|
||||
function disableListeners() {
|
||||
document.removeEventListener('click', onClickOutside);
|
||||
document.removeEventListener('mousedown', onClickOutside);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
}
|
||||
|
||||
|
||||
@ -443,17 +443,13 @@ export default function CommentsList(props) {
|
||||
let split = match.split(':'),
|
||||
s = 0,
|
||||
m = 1;
|
||||
let searchParameters = new URLSearchParams(window.location.search);
|
||||
|
||||
while (split.length > 0) {
|
||||
s += m * parseInt(split.pop(), 10);
|
||||
m *= 60;
|
||||
}
|
||||
searchParameters.set('t', s);
|
||||
|
||||
let mediaUrl = MediaPageStore.get('media-url').split('?')[0] + '?' + searchParameters;
|
||||
|
||||
const wrapped = '<a href="' + mediaUrl + '">' + match + '</a>';
|
||||
const wrapped = `<a href="#" data-timestamp="${s}" class="video-timestamp">${match}</a>`;
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
|
||||
@ -180,16 +180,13 @@ export default function ViewerInfoContent(props) {
|
||||
let split = match.split(':'),
|
||||
s = 0,
|
||||
m = 1;
|
||||
let searchParameters = new URLSearchParams(window.location.search);
|
||||
|
||||
while (split.length > 0) {
|
||||
s += m * parseInt(split.pop(), 10);
|
||||
m *= 60;
|
||||
}
|
||||
searchParameters.set('t', s);
|
||||
|
||||
const wrapped =
|
||||
'<a href="' + MediaPageStore.get('media-url').split('?')[0] + '?' + searchParameters + '">' + match + '</a>';
|
||||
const wrapped = `<a href="#" data-timestamp="${s}" class="video-timestamp">${match}</a>`;
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ export default class ViewerSidebar extends React.PureComponent {
|
||||
isPlaylistPage: !!props.playlistData,
|
||||
activeItem: 0,
|
||||
mediaType: MediaPageStore.get('media-type'),
|
||||
chapters: MediaPageStore.get('media-data')?.chapters
|
||||
};
|
||||
|
||||
if (props.playlistData) {
|
||||
@ -37,6 +38,7 @@ export default class ViewerSidebar extends React.PureComponent {
|
||||
onMediaLoad() {
|
||||
this.setState({
|
||||
mediaType: MediaPageStore.get('media-type'),
|
||||
chapters: MediaPageStore.get('media-data')?.chapter_data || []
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -604,6 +604,20 @@ function findGetParameter(parameterName) {
|
||||
const autoplay = parseInt(findGetParameter('autoplay'));
|
||||
const timestamp = parseInt(findGetParameter('t'));
|
||||
|
||||
// Handle timestamp clicks
|
||||
document.addEventListener('click', function (e) {
|
||||
if (e.target.classList.contains('video-timestamp')) {
|
||||
e.preventDefault();
|
||||
const timestamp = parseInt(e.target.dataset.timestamp, 10);
|
||||
|
||||
if (timestamp >= 0 && timestamp < Player.duration()) {
|
||||
Player.currentTime(timestamp);
|
||||
} else if (timestamp >= 0) {
|
||||
Player.play();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (muted == 1) {
|
||||
Player.muted(true);
|
||||
}
|
||||
|
||||
BIN
static/images/chapter_default.jpg
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
1
static/video_editor/video-editor.css
Normal file
203
static/video_editor/video-editor.js
Normal file
1
static/video_editor/video-editor.js.map
Normal file
@ -422,44 +422,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
const tables = document.querySelectorAll('table.table-sm');
|
||||
|
||||
for (const table of tables) {
|
||||
const userLink = table.querySelector('a[href="/admin/users/user/"]');
|
||||
if (userLink) {
|
||||
const tbody = table.querySelector('tbody');
|
||||
if (tbody) {
|
||||
const emailAddressesRow = document.createElement('tr');
|
||||
emailAddressesRow.innerHTML = `
|
||||
<td>
|
||||
<a href="/admin/account/emailaddress/">Email Addresses</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group float-right">
|
||||
<a href="/admin/account/emailaddress/add/" class="btn btn-xs btn-success addlink">Add</a>
|
||||
<a href="/admin/account/emailaddress/" class="btn btn-xs btn-info changelink">Change</a>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
|
||||
const groupsRow = document.createElement('tr');
|
||||
groupsRow.innerHTML = `
|
||||
<td>
|
||||
<a href="/admin/rbac/rbacgroup/">Groups</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group float-right">
|
||||
<a href="/admin/rbac/rbacgroup/add/" class="btn btn-xs btn-success addlink">Add</a>
|
||||
<a href="/admin/rbac/rbacgroup/" class="btn btn-xs btn-info changelink">Change</a>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(emailAddressesRow);
|
||||
tbody.appendChild(groupsRow);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
82
templates/cms/crispy_custom_field.html
Normal file
@ -0,0 +1,82 @@
|
||||
{% load crispy_forms_field %}
|
||||
|
||||
|
||||
<style>
|
||||
/* Form group styling */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Control label container styling */
|
||||
.control-label-container {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Label styling */
|
||||
.control-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Help text styling */
|
||||
.help-text-inline {
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
margin-left: 5px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Full width fields */
|
||||
.controls.full-width input,
|
||||
.controls.full-width textarea,
|
||||
.controls.full-width select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Error styling */
|
||||
.invalid-feedback {
|
||||
color: #dc3545;
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Button styling */
|
||||
button[type="submit"],
|
||||
input[type="submit"] {
|
||||
background-color: #28a745;
|
||||
border-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button[type="submit"]:hover,
|
||||
input[type="submit"]:hover {
|
||||
background-color: #218838;
|
||||
border-color: #1e7e34;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div class="form-group{% if field.errors %} has-error{% endif %}">
|
||||
<div class="control-label-container">
|
||||
{% if field.label %}
|
||||
<label for="{{ field.id_for_label }}" class="control-label">
|
||||
{{ field.label }}
|
||||
{% if field.help_text %}
|
||||
<span class="help-text-inline">- {{ field.help_text|safe }}</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="controls {% if field.name == 'title' or field.name == 'new_tags' or field.name == 'description' %}full-width{% endif %}">
|
||||
{% crispy_field field %}
|
||||
{% if field.errors %}
|
||||
<div class="error-container">
|
||||
{% for error in field.errors %}
|
||||
<p class="invalid-feedback">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
45
templates/cms/edit_chapters.html
Normal file
@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load static %}
|
||||
|
||||
{% block headtitle %}Edit video chapters - {{PORTAL_NAME}}{% endblock headtitle %}
|
||||
|
||||
{% block topimports %}
|
||||
<link href="{% static "video_editor/video-editor.css" %}" rel="preload" as="style">
|
||||
<link href="{% static "video_editor/video-editor.css" %}" rel="stylesheet">
|
||||
<script>
|
||||
window.MEDIA_DATA = {
|
||||
// videoUrl: "{{ media_file_path }}", // "http://temp.web357.com/SampleVideo_1280x720_30mb.mp4",
|
||||
videoUrl: "http://temp.web357.com/SampleVideo_1280x720_30mb.mp4",
|
||||
mediaId: "{{ media_id }}",
|
||||
chapters: [
|
||||
{
|
||||
id: "1",
|
||||
title: "Chapter AAA",
|
||||
timestamp: 0
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Chapter BBB",
|
||||
timestamp: 10
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Chapter CCC",
|
||||
timestamp: 20
|
||||
}
|
||||
]
|
||||
};
|
||||
</script>
|
||||
<script src="{% static 'video_editor/video-editor.js' %}"></script>
|
||||
{%endblock topimports %}
|
||||
|
||||
{% block innercontent %}
|
||||
|
||||
<div class="user-action-form-wrap">
|
||||
{% include "cms/media_nav.html" with active_tab="chapters" %}
|
||||
<div class="user-action-form-inner" style="max-width: 1280px; margin: 0 auto; padding: 20px; border-radius: 8px; box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1);">
|
||||
<div id="video-editor-chapters-root"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock innercontent %}
|
||||
@ -7,16 +7,12 @@
|
||||
{% block headermeta %}{% endblock headermeta %}
|
||||
|
||||
{% block innercontent %}
|
||||
|
||||
<div class="user-action-form-wrap">
|
||||
<div class="user-action-form-inner">
|
||||
<h1>Edit Media</h1>
|
||||
<form enctype="multipart/form-data" action="" method="post" class="post-form">
|
||||
{% include "cms/media_nav.html" with active_tab="metadata" %}
|
||||
<div style="max-width: 900px; margin: 0 auto; padding: 20px; border-radius: 8px; box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1);">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="primaryAction" type="submit">Update Media</button>
|
||||
</form>
|
||||
{% crispy form %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock innercontent %}
|
||||
|
||||
{% endblock innercontent %}
|
||||
33
templates/cms/edit_video.html
Normal file
@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load static %}
|
||||
|
||||
{% block headtitle %}Edit video - {{PORTAL_NAME}}{% endblock headtitle %}
|
||||
|
||||
{% block topimports %}
|
||||
<link href="{% static "video_editor/video-editor.css" %}" rel="preload" as="style">
|
||||
<link href="{% static "video_editor/video-editor.css" %}" rel="stylesheet">
|
||||
|
||||
<script src="{% static 'video_editor/video-editor.js' %}"></script>
|
||||
|
||||
<script>
|
||||
window.MEDIA_DATA = {
|
||||
videoUrl: "{{ media_file_path }}",
|
||||
mediaId: "{{ media_object.friendly_token }}",
|
||||
redirectURL: "{{ media_object.get_absolute_url }}",
|
||||
redirectUserMediaURL: "{{ media_object.user.get_absolute_url }}"
|
||||
};
|
||||
</script>
|
||||
{%endblock topimports %}
|
||||
|
||||
{% block innercontent %}
|
||||
|
||||
<div class="user-action-form-wrap">
|
||||
{% include "cms/media_nav.html" with active_tab="trim" %}
|
||||
<div class="user-action-form-inner" style="max-width: 1280px; margin: 0 auto; padding: 20px; border-radius: 8px; box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1);">
|
||||
<div id="video-editor-trim-root"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock innercontent %}
|
||||
34
templates/cms/media_nav.html
Normal file
@ -0,0 +1,34 @@
|
||||
<div class="media-edit-nav" style="background-color: #f0f0f0; padding: 10px 0; margin-bottom: 20px;">
|
||||
<ul style="list-style: none; display: flex; justify-content: space-around; margin: 0; padding: 0;">
|
||||
<li style="display: inline-block;">
|
||||
<a href="{% url 'edit_media' %}?m={{media_object.friendly_token}}"
|
||||
style="text-decoration: none; {% if active_tab == 'metadata' %}font-weight: bold; color: #333; padding-bottom: 3px; border-bottom: 2px solid #333;{% else %}color: #666;{% endif %}">
|
||||
Metadata
|
||||
</a>
|
||||
</li>
|
||||
{% if media_object.media_type == 'video' %}
|
||||
<li style="display: inline-block;">
|
||||
<a href="{% url 'edit_video' %}?m={{media_object.friendly_token}}"
|
||||
|
||||
style="text-decoration: none; {% if active_tab == 'trim' %}font-weight: bold; color: #333; padding-bottom: 3px; border-bottom: 2px solid #333;{% else %}color: #666;{% endif %}">
|
||||
Trim
|
||||
</a>
|
||||
</li>
|
||||
{% comment %}
|
||||
<li style="display: inline-block;">
|
||||
<a href="{% url 'edit_chapters' %}?m={{media_object.friendly_token}}"
|
||||
|
||||
style="text-decoration: none; {% if active_tab == 'chapters' %}font-weight: bold; color: #333; padding-bottom: 3px; border-bottom: 2px solid #333;{% else %}color: #666;{% endif %}">
|
||||
Chapters
|
||||
</a>
|
||||
</li>
|
||||
{% endcomment %}
|
||||
{% endif %}
|
||||
<li style="display: inline-block;">
|
||||
<a href="{% url 'publish_media' %}?m={{media_object.friendly_token}}"
|
||||
style="text-decoration: none; {% if active_tab == 'publish' %}font-weight: bold; color: #333; padding-bottom: 3px; border-bottom: 2px solid #333;{% else %}color: #666;{% endif %}">
|
||||
Publish
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||