feat: Video Trimmer and more

This commit is contained in:
Yiannis Christodoulou 2025-05-15 10:43:26 +03:00 committed by GitHub
parent ab4d9d67df
commit a833b606f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
102 changed files with 13266 additions and 523 deletions

4
.gitignore vendored
View File

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

@ -0,0 +1 @@
*

View File

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

View 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!"

View File

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

View File

@ -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
@ -879,7 +873,7 @@ By default there are 3 statuses for any Media that lives on the system, public,
Now user can view the Media even if it is in private state. User also sees all media in Category page
When user is added to group, they can be set as Member, Contributor, Manager.
When user is added to group, they can be set as Member, Contributor, Manager.
- Member: user can view media that are published on one or more categories that this group is associated with
- Contributor: besides viewing, user can also edit the Media in a category associated with this Group. They can also publish Media to this category
@ -891,17 +885,17 @@ Use cases facilitated with RBAC:
- viewing all media of a category: if RBAC is enabled, and user visits a Category, they are able to see the listing of all media that are published in this category, independent of their state, provided that the category is associated with a group that the user is member of
- viewing all categories associated with groups the user is member of: if RBAC is enabled, and user visits the listing of categories, they can view all categories that are associated with a group the user is member
How to enable RBAC support:
How to enable RBAC support:
```
USE_RBAC = True
```
on `local_settings.py` and restart the instance.
on `local_settings.py` and restart the instance.
## 23. SAML setup
SAML authentication is supported along with the option to utilize the SAML response and do useful things as setting up the user role in MediaCMS or participation in groups.
SAML authentication is supported along with the option to utilize the SAML response and do useful things as setting up the user role in MediaCMS or participation in groups.
To enable SAML support, edit local_settings.py and set the following options:
@ -942,8 +936,8 @@ Select the SAML Configurations tab, create a new one and set:
1. **IDP ID**: Must be a URL
2. **IDP Certificate**: x509cert from your SAML provider
3. **SSO URL**:
4. **SLO URL**:
3. **SSO URL**:
4. **SLO URL**:
5. **SP Metadata URL**: The metadata URL that the IDP will utilize. This can be https://{portal}/saml/metadata and is autogenerated by MediaCMS
- Step 3: Set other Options
@ -971,5 +965,5 @@ to enable the identity providers, set the following setting on `local_settings.p
USE_IDENTITY_PROVIDERS = True
```
Visiting the admin, you will see the Identity Providers tab and you can add one.
Visiting the admin, you will see the Identity Providers tab and you can add one.

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@ -161,6 +161,7 @@ class SingleMediaSerializer(serializers.ModelSerializer):
"hls_info",
"license",
"subtitles_info",
"chapter_data",
"ratings_info",
"add_subtitle_url",
"allow_download",

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,7 @@
node_modules
dist
.DS_Store
server/public
vite.config.ts.*
*.tar.gz
yt.readme.md

View File

@ -0,0 +1 @@
*

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

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

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

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

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

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

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

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

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

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

View 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%;
}
}
}

View File

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

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

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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}`);
});
})();

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

View 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();

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

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

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

View 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/*"]
}
}
}

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -15,8 +15,10 @@ export function PopupContent(props) {
}
const domElem = findDOMNode(wrapperRef.current);
if (-1 === ev.path.indexOf(domElem)) {
const clickedElement = ev.target;
// 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);
}

View File

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

View File

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

View File

@ -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 || []
});
}

View File

@ -603,6 +603,20 @@ function findGetParameter(parameterName) {
const muted = parseInt(findGetParameter('muted'));
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);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -389,77 +389,39 @@
document.addEventListener('DOMContentLoaded', function() {
const breadcrumbItems = document.querySelectorAll('.breadcrumb-item a');
breadcrumbItems.forEach(function(link) {
if (link.getAttribute('href') === '/admin/account/') {
link.setAttribute('href', '/admin/users/');
link.textContent = 'Users';
}
if (link.getAttribute('href') === '/admin/rbac/') {
link.setAttribute('href', '/admin/users/');
link.textContent = 'Users';
}
});
const identityProvidersHeader = Array.from(document.querySelectorAll('.nav-header')).find(
header => header.textContent.trim() === 'Identity_Providers'
);
if (identityProvidersHeader) {
identityProvidersHeader.style.display = 'none';
const loginOptionsItem = document.querySelector('.nav-item a[href="/admin/identity_providers/loginoption/"]');
if (loginOptionsItem) {
loginOptionsItem.closest('.nav-item').style.display = 'none';
}
const userLogsItem = document.querySelector('.nav-item a[href="/admin/identity_providers/identityprovideruserlog/"]');
if (userLogsItem) {
userLogsItem.closest('.nav-item').style.display = 'none';
}
}
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>

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

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

View File

@ -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">
<div class="user-action-form-wrap">
{% 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>
</div>
{% crispy form %}
</div>
</div>
{% endblock innercontent %}
{% endblock innercontent %}

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

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

Some files were not shown because too many files have changed in this diff Show More