Compare commits

..

No commits in common. "main" and "v7.2.0" have entirely different histories.
main ... v7.2.0

155 changed files with 5885 additions and 11963 deletions

View File

@ -1,69 +1,2 @@
# Node.js/JavaScript dependencies and artifacts node_modules
**/node_modules npm-debug.log
**/npm-debug.log*
**/yarn-debug.log*
**/yarn-error.log*
**/.yarn/cache
**/.yarn/unplugged
**/package-lock.json
**/.npm
**/.cache
**/.parcel-cache
**/dist
**/build
**/*.tsbuildinfo
# Python bytecode and cache
**/__pycache__
**/*.py[cod]
**/*$py.class
**/*.so
**/.Python
**/pip-log.txt
**/pip-delete-this-directory.txt
**/.pytest_cache
**/.coverage
**/htmlcov
**/.tox
**/.mypy_cache
**/.ruff_cache
# Version control
**/.git
**/.gitignore
**/.gitattributes
# IDE and editor files
**/.DS_Store
**/.vscode
**/.idea
**/*.swp
**/*.swo
**/*~
# Logs and runtime files
**/logs
**/*.log
**/celerybeat-schedule*
**/.env
**/.env.*
# Media files and data directories (should not be in image)
media_files/**
postgres_data/**
pids/**
# Static files collected at runtime
static_collected/**
# Documentation and development files
**/.github
**/CHANGELOG.md
# Test files and directories
**/tests
**/test_*.py
**/*_test.py
# Frontend build artifacts (built separately)
frontend/dist/**

View File

@ -1,42 +0,0 @@
name: Frontend build and test
on:
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
strategy:
matrix:
os: [ubuntu-latest]
node: [20]
runs-on: ${{ matrix.os }}
name: '${{ matrix.os }} - node v${{ matrix.node }}'
permissions:
contents: read
defaults:
run:
working-directory: ./frontend
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Install dependencies
run: npm install
- name: Build script
run: npm run dist
- name: Test script
run: npm run test

8
.gitignore vendored
View File

@ -6,9 +6,8 @@ media_files/hls/
media_files/chunks/ media_files/chunks/
media_files/uploads/ media_files/uploads/
media_files/tinymce_media/ media_files/tinymce_media/
media_files/userlogos/
postgres_data/ postgres_data/
celerybeat-schedule* celerybeat-schedule
logs/ logs/
pids/ pids/
static/admin/ static/admin/
@ -20,8 +19,8 @@ static/drf-yasg
cms/local_settings.py cms/local_settings.py
deploy/docker/local_settings.py deploy/docker/local_settings.py
yt.readme.md yt.readme.md
# Node.js dependencies (covers all node_modules directories, including frontend-tools) /frontend-tools/video-editor/node_modules
**/node_modules/ /frontend-tools/video-editor/client/node_modules
/static_collected /static_collected
/frontend-tools/video-editor-v1 /frontend-tools/video-editor-v1
frontend-tools/.DS_Store frontend-tools/.DS_Store
@ -36,4 +35,3 @@ frontend-tools/video-editor/client/public/videos/sample-video.mp3
frontend-tools/chapters-editor/client/public/videos/sample-video.mp3 frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
static/chapters_editor/videos/sample-video.mp3 static/chapters_editor/videos/sample-video.mp3
static/video_editor/videos/sample-video.mp3 static/video_editor/videos/sample-video.mp3
templates/todo-MS4.md

View File

@ -69,7 +69,7 @@ Copyright Markos Gogoulos.
## Support and paid services ## Support and paid services
We provide custom installations, development of extra functionality, migration from existing systems, integrations with legacy systems, training and support. Checkout our [services page](https://mediacms.io/#services/) for more information. We provide custom installations, development of extra functionality, migration from existing systems, integrations with legacy systems, training and support. Contact us at info@mediacms.io for more information.
### Commercial Hostings ### Commercial Hostings
**Elestio** **Elestio**

View File

@ -563,8 +563,7 @@ ALLOW_VIDEO_TRIMMER = True
ALLOW_CUSTOM_MEDIA_URLS = False ALLOW_CUSTOM_MEDIA_URLS = False
ALLOW_MEDIA_REPLACEMENT = False # Whether to allow anonymous users to list all users
ALLOW_ANONYMOUS_USER_LISTING = True ALLOW_ANONYMOUS_USER_LISTING = True
# Who can see the members page # Who can see the members page

View File

@ -1 +1 @@
VERSION = "7.7" VERSION = "7.2.0"

View File

@ -58,7 +58,6 @@ def stuff(request):
ret["USE_RBAC"] = settings.USE_RBAC ret["USE_RBAC"] = settings.USE_RBAC
ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS
ret["INCLUDE_LISTING_NUMBERS"] = settings.INCLUDE_LISTING_NUMBERS ret["INCLUDE_LISTING_NUMBERS"] = settings.INCLUDE_LISTING_NUMBERS
ret["ALLOW_MEDIA_REPLACEMENT"] = getattr(settings, 'ALLOW_MEDIA_REPLACEMENT', False)
ret["VERSION"] = VERSION ret["VERSION"] = VERSION
if request.user.is_superuser: if request.user.is_superuser:

View File

@ -6,7 +6,6 @@ from django.conf import settings
from .methods import get_next_state, is_mediacms_editor from .methods import get_next_state, is_mediacms_editor
from .models import MEDIA_STATES, Category, Media, Subtitle from .models import MEDIA_STATES, Category, Media, Subtitle
from .widgets import CategoryModalWidget
class CustomField(Field): class CustomField(Field):
@ -122,18 +121,13 @@ class MediaPublishForm(forms.ModelForm):
fields = ("category", "state", "featured", "reported_times", "is_reviewed", "allow_download") fields = ("category", "state", "featured", "reported_times", "is_reviewed", "allow_download")
widgets = { widgets = {
"category": CategoryModalWidget(), "category": MultipleSelect(),
} }
def __init__(self, user, *args, **kwargs): def __init__(self, user, *args, **kwargs):
self.user = user self.user = user
super(MediaPublishForm, self).__init__(*args, **kwargs) super(MediaPublishForm, self).__init__(*args, **kwargs)
self.has_custom_permissions = self.instance.permissions.exists() if self.instance.pk else False
self.has_rbac_categories = self.instance.category.filter(is_rbac_category=True).exists() if self.instance.pk else False
self.is_shared = self.has_custom_permissions or self.has_rbac_categories
self.actual_state = self.instance.state if self.instance.pk else None
if not is_mediacms_editor(user): if not is_mediacms_editor(user):
for field in ["featured", "reported_times", "is_reviewed"]: for field in ["featured", "reported_times", "is_reviewed"]:
self.fields[field].disabled = True self.fields[field].disabled = True
@ -146,13 +140,6 @@ class MediaPublishForm(forms.ModelForm):
valid_states.append(self.instance.state) valid_states.append(self.instance.state)
self.fields["state"].choices = [(state, dict(MEDIA_STATES).get(state, state)) for state in valid_states] self.fields["state"].choices = [(state, dict(MEDIA_STATES).get(state, state)) for state in valid_states]
if self.is_shared:
current_choices = list(self.fields["state"].choices)
current_choices.insert(0, ("shared", "Shared"))
self.fields["state"].choices = current_choices
self.fields["state"].initial = "shared"
self.initial["state"] = "shared"
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields: if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
if is_mediacms_editor(user): if is_mediacms_editor(user):
pass pass
@ -191,76 +178,34 @@ class MediaPublishForm(forms.ModelForm):
state = cleaned_data.get("state") state = cleaned_data.get("state")
categories = cleaned_data.get("category") categories = cleaned_data.get("category")
if self.is_shared and state != "shared": if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
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 is not None:
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'):
if state == 'private':
error_parts = []
if self.has_rbac_categories:
rbac_cat_titles = self.instance.category.filter(is_rbac_category=True).values_list('title', flat=True)
error_parts.append(f"shared with users that have access to categories: {', '.join(rbac_cat_titles)}")
if self.has_custom_permissions:
error_parts.append("shared by me with other users (visible in 'Shared by me' page)")
error_message = f"I understand that changing to Private will remove all sharing. Currently this media is {' and '.join(error_parts)}. All this sharing will be removed."
self.add_error('confirm_state', error_message)
else:
error_message = f"I understand that changing to {state.title()} will maintain existing sharing settings."
self.add_error('confirm_state', error_message)
elif state in ['private', 'unlisted']:
custom_permissions = self.instance.permissions.exists()
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True) rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
if rbac_categories or custom_permissions:
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() self.fields['confirm_state'].widget = forms.CheckboxInput()
# add it after the state field
state_index = None state_index = None
for i, layout_item in enumerate(self.helper.layout): for i, layout_item in enumerate(self.helper.layout):
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state': if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
state_index = i state_index = i
break break
if state_index is not None: if state_index:
layout_items = list(self.helper.layout) layout_items = list(self.helper.layout)
layout_items.insert(state_index + 1, CustomField('confirm_state')) layout_items.insert(state_index + 1, CustomField('confirm_state'))
self.helper.layout = Layout(*layout_items) self.helper.layout = Layout(*layout_items)
if not cleaned_data.get('confirm_state'): if not cleaned_data.get('confirm_state'):
if rbac_categories: 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)}"
error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to categories: {', '.join(rbac_categories)}" self.add_error('confirm_state', error_message)
self.add_error('confirm_state', error_message)
if custom_permissions:
error_message = f"I understand that although media state is {state}, the media is also shared by me with other users, that I can see in the 'Shared by me' page"
self.add_error('confirm_state', error_message)
# Convert "shared" state to actual underlying state for saving. we dont keep shared state in DB
if state == "shared":
cleaned_data["state"] = self.actual_state
return cleaned_data return cleaned_data
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
data = self.cleaned_data data = self.cleaned_data
state = data.get("state") state = data.get("state")
# If transitioning from shared to private, remove all sharing
if self.is_shared and state == 'private' and data.get('confirm_state'):
# Remove all custom permissions
self.instance.permissions.all().delete()
# Remove RBAC categories
rbac_cats = self.instance.category.filter(is_rbac_category=True)
self.instance.category.remove(*rbac_cats)
if state != self.initial["state"]: if state != self.initial["state"]:
self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state) self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state)
@ -387,35 +332,3 @@ class ContactForm(forms.Form):
if user.is_authenticated: if user.is_authenticated:
self.fields.pop("name") self.fields.pop("name")
self.fields.pop("from_email") self.fields.pop("from_email")
class ReplaceMediaForm(forms.Form):
new_media_file = forms.FileField(
required=True,
label="New Media File",
help_text="Select a new file to replace the current media",
)
def __init__(self, media_instance, *args, **kwargs):
self.media_instance = media_instance
super(ReplaceMediaForm, self).__init__(*args, **kwargs)
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('new_media_file'),
)
self.helper.layout.append(FormActions(Submit('submit', 'Replace Media', css_class='primaryAction')))
def clean_new_media_file(self):
file = self.cleaned_data.get("new_media_file", False)
if file:
if file.size > settings.UPLOAD_MAX_SIZE:
max_size_mb = settings.UPLOAD_MAX_SIZE / (1024 * 1024)
raise forms.ValidationError(f"File too large. Maximum size: {max_size_mb:.0f}MB")
return file

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "إزالة من القائمة", "Remove from list": "إزالة من القائمة",
"Remove tag": "إزالة العلامة", "Remove tag": "إزالة العلامة",
"Remove user": "إزالة المستخدم", "Remove user": "إزالة المستخدم",
"Replace": "",
"SAVE": "حفظ", "SAVE": "حفظ",
"SEARCH": "بحث", "SEARCH": "بحث",
"SHARE": "مشاركة", "SHARE": "مشاركة",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",
"Remove user": "", "Remove user": "",
"Replace": "",
"SAVE": "সংরক্ষণ করুন", "SAVE": "সংরক্ষণ করুন",
"SEARCH": "অনুসন্ধান", "SEARCH": "অনুসন্ধান",
"SHARE": "শেয়ার করুন", "SHARE": "শেয়ার করুন",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Fjern fra liste", "Remove from list": "Fjern fra liste",
"Remove tag": "Fjern tag", "Remove tag": "Fjern tag",
"Remove user": "Fjern bruger", "Remove user": "Fjern bruger",
"Replace": "",
"SAVE": "GEM", "SAVE": "GEM",
"SEARCH": "SØG", "SEARCH": "SØG",
"SHARE": "DEL", "SHARE": "DEL",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Aus Liste entfernen", "Remove from list": "Aus Liste entfernen",
"Remove tag": "Tag entfernen", "Remove tag": "Tag entfernen",
"Remove user": "Benutzer entfernen", "Remove user": "Benutzer entfernen",
"Replace": "",
"SAVE": "SPEICHERN", "SAVE": "SPEICHERN",
"SEARCH": "SUCHE", "SEARCH": "SUCHE",
"SHARE": "TEILEN", "SHARE": "TEILEN",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Αφαίρεση από λίστα", "Remove from list": "Αφαίρεση από λίστα",
"Remove tag": "Αφαίρεση ετικέτας", "Remove tag": "Αφαίρεση ετικέτας",
"Remove user": "Αφαίρεση χρήστη", "Remove user": "Αφαίρεση χρήστη",
"Replace": "",
"SAVE": "ΑΠΟΘΗΚΕΥΣΗ", "SAVE": "ΑΠΟΘΗΚΕΥΣΗ",
"SEARCH": "ΑΝΑΖΗΤΗΣΗ", "SEARCH": "ΑΝΑΖΗΤΗΣΗ",
"SHARE": "ΚΟΙΝΟΠΟΙΗΣΗ", "SHARE": "ΚΟΙΝΟΠΟΙΗΣΗ",

View File

@ -165,7 +165,6 @@ translation_strings = {
"Recommended": "", "Recommended": "",
"Record Screen": "", "Record Screen": "",
"Register": "", "Register": "",
"Replace": "",
"Remove category": "", "Remove category": "",
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Eliminar de la lista", "Remove from list": "Eliminar de la lista",
"Remove tag": "Eliminar etiqueta", "Remove tag": "Eliminar etiqueta",
"Remove user": "Eliminar usuario", "Remove user": "Eliminar usuario",
"Replace": "",
"SAVE": "GUARDAR", "SAVE": "GUARDAR",
"SEARCH": "BUSCAR", "SEARCH": "BUSCAR",
"SHARE": "COMPARTIR", "SHARE": "COMPARTIR",

View File

@ -163,7 +163,6 @@ translation_strings = {
"Remove from list": "Supprimer de la liste", "Remove from list": "Supprimer de la liste",
"Remove tag": "Supprimer le tag", "Remove tag": "Supprimer le tag",
"Remove user": "Supprimer l'utilisateur", "Remove user": "Supprimer l'utilisateur",
"Replace": "",
"SAVE": "ENREGISTRER", "SAVE": "ENREGISTRER",
"SEARCH": "RECHERCHER", "SEARCH": "RECHERCHER",
"SHARE": "PARTAGER", "SHARE": "PARTAGER",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",
"Remove user": "", "Remove user": "",
"Replace": "",
"SAVE": "שמור", "SAVE": "שמור",
"SEARCH": "חפש", "SEARCH": "חפש",
"SHARE": "שתף", "SHARE": "שתף",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "सूची से हटाएं", "Remove from list": "सूची से हटाएं",
"Remove tag": "टैग हटाएं", "Remove tag": "टैग हटाएं",
"Remove user": "उपयोगकर्ता हटाएं", "Remove user": "उपयोगकर्ता हटाएं",
"Replace": "",
"SAVE": "सहेजें", "SAVE": "सहेजें",
"SEARCH": "खोजें", "SEARCH": "खोजें",
"SHARE": "साझा करें", "SHARE": "साझा करें",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Hapus dari daftar", "Remove from list": "Hapus dari daftar",
"Remove tag": "Hapus tag", "Remove tag": "Hapus tag",
"Remove user": "Hapus pengguna", "Remove user": "Hapus pengguna",
"Replace": "",
"SAVE": "SIMPAN", "SAVE": "SIMPAN",
"SEARCH": "CARI", "SEARCH": "CARI",
"SHARE": "BAGIKAN", "SHARE": "BAGIKAN",

View File

@ -163,7 +163,6 @@ translation_strings = {
"Remove from list": "Rimuovi dalla lista", "Remove from list": "Rimuovi dalla lista",
"Remove tag": "Rimuovi tag", "Remove tag": "Rimuovi tag",
"Remove user": "Rimuovi utente", "Remove user": "Rimuovi utente",
"Replace": "",
"SAVE": "SALVA", "SAVE": "SALVA",
"SEARCH": "CERCA", "SEARCH": "CERCA",
"SHARE": "CONDIVIDI", "SHARE": "CONDIVIDI",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "リストから削除", "Remove from list": "リストから削除",
"Remove tag": "タグを削除", "Remove tag": "タグを削除",
"Remove user": "ユーザーを削除", "Remove user": "ユーザーを削除",
"Replace": "",
"SAVE": "保存", "SAVE": "保存",
"SEARCH": "検索", "SEARCH": "検索",
"SHARE": "共有", "SHARE": "共有",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "목록에서 제거", "Remove from list": "목록에서 제거",
"Remove tag": "태그 제거", "Remove tag": "태그 제거",
"Remove user": "사용자 제거", "Remove user": "사용자 제거",
"Replace": "",
"SAVE": "저장", "SAVE": "저장",
"SEARCH": "검색", "SEARCH": "검색",
"SHARE": "공유", "SHARE": "공유",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Verwijderen uit lijst", "Remove from list": "Verwijderen uit lijst",
"Remove tag": "Tag verwijderen", "Remove tag": "Tag verwijderen",
"Remove user": "Gebruiker verwijderen", "Remove user": "Gebruiker verwijderen",
"Replace": "",
"SAVE": "OPSLAAN", "SAVE": "OPSLAAN",
"SEARCH": "ZOEKEN", "SEARCH": "ZOEKEN",
"SHARE": "DELEN", "SHARE": "DELEN",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Remover da lista", "Remove from list": "Remover da lista",
"Remove tag": "Remover tag", "Remove tag": "Remover tag",
"Remove user": "Remover usuário", "Remove user": "Remover usuário",
"Replace": "",
"SAVE": "SALVAR", "SAVE": "SALVAR",
"SEARCH": "PESQUISAR", "SEARCH": "PESQUISAR",
"SHARE": "COMPARTILHAR", "SHARE": "COMPARTILHAR",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Удалить из списка", "Remove from list": "Удалить из списка",
"Remove tag": "Удалить тег", "Remove tag": "Удалить тег",
"Remove user": "Удалить пользователя", "Remove user": "Удалить пользователя",
"Replace": "",
"SAVE": "СОХРАНИТЬ", "SAVE": "СОХРАНИТЬ",
"SEARCH": "ПОИСК", "SEARCH": "ПОИСК",
"SHARE": "ПОДЕЛИТЬСЯ", "SHARE": "ПОДЕЛИТЬСЯ",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Odstrani s seznama", "Remove from list": "Odstrani s seznama",
"Remove tag": "Odstrani oznako", "Remove tag": "Odstrani oznako",
"Remove user": "Odstrani uporabnika", "Remove user": "Odstrani uporabnika",
"Replace": "",
"SAVE": "SHRANI", "SAVE": "SHRANI",
"SEARCH": "ISKANJE", "SEARCH": "ISKANJE",
"SHARE": "DELI", "SHARE": "DELI",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Listeden kaldır", "Remove from list": "Listeden kaldır",
"Remove tag": "Etiketi kaldır", "Remove tag": "Etiketi kaldır",
"Remove user": "Kullanıcıyı kaldır", "Remove user": "Kullanıcıyı kaldır",
"Replace": "",
"SAVE": "KAYDET", "SAVE": "KAYDET",
"SEARCH": "ARA", "SEARCH": "ARA",
"SHARE": "PAYLAŞ", "SHARE": "PAYLAŞ",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "فہرست سے ہٹائیں", "Remove from list": "فہرست سے ہٹائیں",
"Remove tag": "ٹیگ ہٹائیں", "Remove tag": "ٹیگ ہٹائیں",
"Remove user": "صارف ہٹائیں", "Remove user": "صارف ہٹائیں",
"Replace": "",
"SAVE": "محفوظ کریں", "SAVE": "محفوظ کریں",
"SEARCH": "تلاش کریں", "SEARCH": "تلاش کریں",
"SHARE": "شیئر کریں", "SHARE": "شیئر کریں",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",
"Remove user": "", "Remove user": "",
"Replace": "",
"SAVE": "保存", "SAVE": "保存",
"SEARCH": "搜索", "SEARCH": "搜索",
"SHARE": "分享", "SHARE": "分享",

View File

@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",
"Remove user": "", "Remove user": "",
"Replace": "",
"SAVE": "儲存", "SAVE": "儲存",
"SEARCH": "搜尋", "SEARCH": "搜尋",
"SHARE": "分享", "SHARE": "分享",

View File

@ -910,9 +910,7 @@ def trim_video_method(media_file_path, timestamps_list):
return False return False
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir: with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
# Detect input file extension to preserve original format output_file = os.path.join(temp_dir, "output.mp4")
_, input_ext = os.path.splitext(media_file_path)
output_file = os.path.join(temp_dir, f"output{input_ext}")
segment_files = [] segment_files = []
for i, item in enumerate(timestamps_list): for i, item in enumerate(timestamps_list):
@ -922,7 +920,7 @@ def trim_video_method(media_file_path, timestamps_list):
# For single timestamp, we can use the output file directly # For single timestamp, we can use the output file directly
# For multiple timestamps, we need to create segment files # 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}{input_ext}") 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] cmd = [settings.FFMPEG_COMMAND, "-y", "-ss", str(item['startTime']), "-i", media_file_path, "-t", str(duration), "-c", "copy", "-avoid_negative_ts", "1", segment_file]

View File

@ -494,6 +494,7 @@ def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"):
state=helpers.get_default_state(user=original_media.user), state=helpers.get_default_state(user=original_media.user),
is_reviewed=original_media.is_reviewed, is_reviewed=original_media.is_reviewed,
encoding_status=original_media.encoding_status, encoding_status=original_media.encoding_status,
listable=original_media.listable,
add_date=timezone.now(), add_date=timezone.now(),
video_height=original_media.video_height, video_height=original_media.video_height,
size=original_media.size, size=original_media.size,
@ -713,6 +714,7 @@ def copy_media(media):
state=helpers.get_default_state(user=media.user), state=helpers.get_default_state(user=media.user),
is_reviewed=media.is_reviewed, is_reviewed=media.is_reviewed,
encoding_status=media.encoding_status, encoding_status=media.encoding_status,
listable=media.listable,
add_date=timezone.now(), add_date=timezone.now(),
) )

View File

@ -1,24 +0,0 @@
# Generated by Django 5.2.6 on 2025-12-16 14:05
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('files', '0013_page_tinymcemedia'),
]
operations = [
migrations.AlterModelOptions(
name='subtitle',
options={'ordering': ['language__title'], 'verbose_name': 'Caption', 'verbose_name_plural': 'Captions'},
),
migrations.AlterModelOptions(
name='transcriptionrequest',
options={'verbose_name': 'Caption Request', 'verbose_name_plural': 'Caption Requests'},
),
migrations.AlterModelOptions(
name='videotrimrequest',
options={'verbose_name': 'Trim Request', 'verbose_name_plural': 'Trim Requests'},
),
]

View File

@ -270,9 +270,7 @@ class Media(models.Model):
if self.media_file != self.__original_media_file: if self.media_file != self.__original_media_file:
# set this otherwise gets to infinite loop # set this otherwise gets to infinite loop
self.__original_media_file = self.media_file self.__original_media_file = self.media_file
from .. import tasks self.media_init()
tasks.media_init.apply_async(args=[self.friendly_token], countdown=5)
# for video files, if user specified a different time # for video files, if user specified a different time
# to automatically grub thumbnail # to automatically grub thumbnail
@ -284,7 +282,7 @@ class Media(models.Model):
self.allow_whisper_transcribe != self.__original_allow_whisper_transcribe or self.allow_whisper_transcribe_and_translate != self.__original_allow_whisper_transcribe_and_translate self.allow_whisper_transcribe != self.__original_allow_whisper_transcribe or self.allow_whisper_transcribe_and_translate != self.__original_allow_whisper_transcribe_and_translate
) )
if transcription_changed and self.media_type in ["video", "audio"]: if transcription_changed and self.media_type == "video":
self.transcribe_function() self.transcribe_function()
# Update the original values for next comparison # Update the original values for next comparison
@ -331,17 +329,10 @@ class Media(models.Model):
if to_transcribe: if to_transcribe:
TranscriptionRequest.objects.create(media=self, translate_to_english=False) TranscriptionRequest.objects.create(media=self, translate_to_english=False)
tasks.whisper_transcribe.apply_async( tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=False)
args=[self.friendly_token, False],
countdown=10,
)
if to_transcribe_and_translate: if to_transcribe_and_translate:
TranscriptionRequest.objects.create(media=self, translate_to_english=True) TranscriptionRequest.objects.create(media=self, translate_to_english=True)
tasks.whisper_transcribe.apply_async( tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=True)
args=[self.friendly_token, True],
countdown=10,
)
def update_search_vector(self): def update_search_vector(self):
""" """
@ -419,11 +410,6 @@ class Media(models.Model):
self.media_type = "image" self.media_type = "image"
elif kind == "pdf": elif kind == "pdf":
self.media_type = "pdf" self.media_type = "pdf"
elif kind == "audio":
self.media_type = "audio"
elif kind == "video":
self.media_type = "video"
if self.media_type in ["image", "pdf"]: if self.media_type in ["image", "pdf"]:
self.encoding_status = "success" self.encoding_status = "success"
else: else:
@ -777,8 +763,6 @@ class Media(models.Model):
return helpers.url_from_path(self.uploaded_thumbnail.path) return helpers.url_from_path(self.uploaded_thumbnail.path)
if self.thumbnail: if self.thumbnail:
return helpers.url_from_path(self.thumbnail.path) return helpers.url_from_path(self.thumbnail.path)
if self.media_type == "audio":
return helpers.url_from_path("userlogos/poster_audio.jpg")
return None return None
@property @property
@ -792,9 +776,6 @@ class Media(models.Model):
return helpers.url_from_path(self.uploaded_poster.path) return helpers.url_from_path(self.uploaded_poster.path)
if self.poster: if self.poster:
return helpers.url_from_path(self.poster.path) return helpers.url_from_path(self.poster.path)
if self.media_type == "audio":
return helpers.url_from_path("userlogos/poster_audio.jpg")
return None return None
@property @property

View File

@ -101,17 +101,10 @@ class MediaSerializer(serializers.ModelSerializer):
class SingleMediaSerializer(serializers.ModelSerializer): class SingleMediaSerializer(serializers.ModelSerializer):
user = serializers.ReadOnlyField(source="user.username") user = serializers.ReadOnlyField(source="user.username")
url = serializers.SerializerMethodField() url = serializers.SerializerMethodField()
is_shared = serializers.SerializerMethodField()
def get_url(self, obj): def get_url(self, obj):
return self.context["request"].build_absolute_uri(obj.get_absolute_url()) return self.context["request"].build_absolute_uri(obj.get_absolute_url())
def get_is_shared(self, obj):
"""Check if media has custom permissions or RBAC categories"""
custom_permissions = obj.permissions.exists()
rbac_categories = obj.category.filter(is_rbac_category=True).exists()
return custom_permissions or rbac_categories
class Meta: class Meta:
model = Media model = Media
read_only_fields = ( read_only_fields = (
@ -140,7 +133,6 @@ class SingleMediaSerializer(serializers.ModelSerializer):
"edit_date", "edit_date",
"media_type", "media_type",
"state", "state",
"is_shared",
"duration", "duration",
"thumbnail_url", "thumbnail_url",
"poster_url", "poster_url",

View File

@ -625,18 +625,6 @@ def create_hls(friendly_token):
return True return True
@task(name="media_init", queue="short_tasks")
def media_init(friendly_token):
try:
media = Media.objects.get(friendly_token=friendly_token)
except: # noqa
logger.info("failed to get media with friendly_token %s" % friendly_token)
return False
media.media_init()
return True
@task(name="check_running_states", queue="short_tasks") @task(name="check_running_states", queue="short_tasks")
def check_running_states(): def check_running_states():
# Experimental - unused # Experimental - unused

View File

@ -20,7 +20,6 @@ urlpatterns = [
re_path(r"^contact$", views.contact, name="contact"), re_path(r"^contact$", views.contact, name="contact"),
re_path(r"^publish", views.publish_media, name="publish_media"), 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_chapters", views.edit_chapters, name="edit_chapters"),
re_path(r"^replace_media", views.replace_media, name="replace_media"),
re_path(r"^edit_video", views.edit_video, name="edit_video"), re_path(r"^edit_video", views.edit_video, name="edit_video"),
re_path(r"^edit", views.edit_media, name="edit_media"), re_path(r"^edit", views.edit_media, name="edit_media"),
re_path(r"^embed", views.embed_media, name="get_embed"), re_path(r"^embed", views.embed_media, name="get_embed"),
@ -111,7 +110,7 @@ urlpatterns = [
re_path(r"^manage/users$", views.manage_users, name="manage_users"), re_path(r"^manage/users$", views.manage_users, name="manage_users"),
# Media uploads in ADMIN created pages # Media uploads in ADMIN created pages
re_path(r"^tinymce/upload/", tinymce_handlers.upload_image, name="tinymce_upload_image"), re_path(r"^tinymce/upload/", tinymce_handlers.upload_image, name="tinymce_upload_image"),
re_path(r"^(?P<slug>[\w.-]*)$", views.get_page, name="get_page"), # noqa: W605 re_path("^(?P<slug>[\w.-]*)$", views.get_page, name="get_page"), # noqa: W605
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@ -32,7 +32,6 @@ from .pages import members # noqa: F401
from .pages import publish_media # noqa: F401 from .pages import publish_media # noqa: F401
from .pages import recommended_media # noqa: F401 from .pages import recommended_media # noqa: F401
from .pages import record_screen # noqa: F401 from .pages import record_screen # noqa: F401
from .pages import replace_media # noqa: F401
from .pages import search # noqa: F401 from .pages import search # noqa: F401
from .pages import setlanguage # noqa: F401 from .pages import setlanguage # noqa: F401
from .pages import sitemap # noqa: F401 from .pages import sitemap # noqa: F401

View File

@ -226,13 +226,8 @@ class MediaList(APIView):
elif duration == '60-120': elif duration == '60-120':
media = media.filter(duration__gte=3600) media = media.filter(duration__gte=3600)
if publish_state: if publish_state and publish_state in ['private', 'public', 'unlisted']:
if publish_state == 'shared': media = media.filter(state=publish_state)
# Filter media that have custom permissions OR RBAC categories
shared_conditions = Q(permissions__isnull=False) | Q(category__is_rbac_category=True)
media = media.filter(shared_conditions).distinct()
elif publish_state in ['private', 'public', 'unlisted']:
media = media.filter(state=publish_state)
if not already_sorted: if not already_sorted:
media = media.order_by(f"{ordering}{sort_by}") media = media.order_by(f"{ordering}{sort_by}")
@ -804,14 +799,13 @@ class MediaDetail(APIView):
serializer = MediaSerializer(media, data=request.data, context={"request": request}) serializer = MediaSerializer(media, data=request.data, context={"request": request})
if serializer.is_valid(): if serializer.is_valid():
# if request.data.get('media_file'):
# media_file = request.data["media_file"]
# media.state = helpers.get_default_state(request.user)
# media.listable = False
# serializer.save(user=request.user, media_file=media_file)
# else:
# serializer.save(user=request.user)
serializer.save(user=request.user) serializer.save(user=request.user)
# no need to update the media file itself, only the metadata
# if request.data.get('media_file'):
# media_file = request.data["media_file"]
# serializer.save(user=request.user, media_file=media_file)
# else:
# serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@ -1,5 +1,4 @@
import json import json
import os
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
@ -19,7 +18,6 @@ from ..forms import (
EditSubtitleForm, EditSubtitleForm,
MediaMetadataForm, MediaMetadataForm,
MediaPublishForm, MediaPublishForm,
ReplaceMediaForm,
SubtitleForm, SubtitleForm,
WhisperSubtitlesForm, WhisperSubtitlesForm,
) )
@ -365,76 +363,6 @@ def publish_media(request):
) )
@login_required
def replace_media(request):
"""Replace media file"""
if not getattr(settings, 'ALLOW_MEDIA_REPLACEMENT', False):
return HttpResponseRedirect("/")
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.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if not is_media_allowed_type(media):
return HttpResponseRedirect(media.get_absolute_url())
if request.method == "POST":
form = ReplaceMediaForm(media, request.POST, request.FILES)
if form.is_valid():
new_media_file = form.cleaned_data.get("new_media_file")
media.encodings.all().delete()
if media.thumbnail:
helpers.rm_file(media.thumbnail.path)
media.thumbnail = None
if media.poster:
helpers.rm_file(media.poster.path)
media.poster = None
if media.uploaded_thumbnail:
helpers.rm_file(media.uploaded_thumbnail.path)
media.uploaded_thumbnail = None
if media.uploaded_poster:
helpers.rm_file(media.uploaded_poster.path)
media.uploaded_poster = None
if media.sprites:
helpers.rm_file(media.sprites.path)
media.sprites = None
if media.preview_file_path:
helpers.rm_file(media.preview_file_path)
media.preview_file_path = ""
if media.hls_file:
hls_dir = os.path.dirname(media.hls_file)
helpers.rm_dir(hls_dir)
media.hls_file = ""
media.media_file = new_media_file
media.listable = False
media.state = helpers.get_default_state(request.user)
media.save()
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media file was replaced successfully"))
return HttpResponseRedirect(media.get_absolute_url())
else:
form = ReplaceMediaForm(media)
return render(
request,
"cms/replace_media.html",
{"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url},
)
@login_required @login_required
def edit_chapters(request): def edit_chapters(request):
"""Edit chapters""" """Edit chapters"""

View File

@ -1,39 +0,0 @@
import json
from django import forms
from django.utils.safestring import mark_safe
class CategoryModalWidget(forms.SelectMultiple):
"""Two-panel category selector with modal"""
class Media:
css = {'all': ('css/category_modal.css',)}
js = ('js/category_modal.js',)
def render(self, name, value, attrs=None, renderer=None):
# Get all categories as JSON
categories = []
for opt_value, opt_label in self.choices:
if opt_value: # Skip empty choice
categories.append({'id': str(opt_value), 'title': str(opt_label)})
all_categories_json = json.dumps(categories)
selected_ids_json = json.dumps([str(v) for v in (value or [])])
html = f'''<div class="category-widget" data-name="{name}">
<div class="category-content">
<div class="category-panel">
<input type="text" class="category-search" placeholder="Search categories...">
<div class="category-list scrollable" data-panel="left"></div>
</div>
<div class="category-panel">
<h3>Selected Categories</h3>
<div class="category-list scrollable" data-panel="right"></div>
</div>
</div>
<div class="hidden-inputs"></div>
<script type="application/json" class="category-data">{{"all":{all_categories_json},"selected":{selected_ids_json}}}</script>
</div>'''
return mark_safe(html)

View File

@ -150,11 +150,6 @@ const App = () => {
canRedo={historyPosition < history.length - 1} canRedo={historyPosition < history.length - 1}
/> />
{/* Timeline Header */}
<div className="timeline-header-container">
<h2 className="timeline-header-title">Add Chapters</h2>
</div>
{/* Timeline Controls */} {/* Timeline Controls */}
<TimelineControls <TimelineControls
currentTime={currentTime} currentTime={currentTime}

View File

@ -28,9 +28,9 @@ const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => {
// Generate the same color background for a segment as shown in the timeline // Generate the same color background for a segment as shown in the timeline
const getSegmentColorClass = (index: number) => { const getSegmentColorClass = (index: number) => {
// Return CSS class based on index modulo 20 // Return CSS class based on index modulo 8
// This matches the CSS classes for up to 20 segments // This matches the CSS nth-child selectors in the timeline
return `segment-default-color segment-color-${(index % 20) + 1}`; return `segment-default-color segment-color-${(index % 8) + 1}`;
}; };
// Get selected segment // Get selected segment
@ -65,8 +65,8 @@ const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => {
<div className="segment-actions"> <div className="segment-actions">
<button <button
className="delete-button" className="delete-button"
aria-label="Delete Chapter" aria-label="Delete Segment"
data-tooltip="Delete this chapter" data-tooltip="Delete this segment"
onClick={() => handleDeleteSegment(segment.id)} onClick={() => handleDeleteSegment(segment.id)}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">

View File

@ -26,6 +26,18 @@ const mediaPageLinkStyles = {
}, },
} as const; } as const;
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
const parseTimeToSeconds = (timeString: string): number => {
const parts = timeString.split(':');
if (parts.length !== 3) return 0;
const hours = parseInt(parts[0], 10) || 0;
const minutes = parseInt(parts[1], 10) || 0;
const seconds = parseFloat(parts[2]) || 0;
return hours * 3600 + minutes * 60 + seconds;
};
interface TimelineControlsProps { interface TimelineControlsProps {
currentTime: number; currentTime: number;
duration: number; duration: number;
@ -177,16 +189,7 @@ const TimelineControls = ({
const [isAutoSaving, setIsAutoSaving] = useState(false); const [isAutoSaving, setIsAutoSaving] = useState(false);
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null); const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const clipSegmentsRef = useRef(clipSegments); const clipSegmentsRef = useRef(clipSegments);
// Track when a drag just ended to prevent Safari from triggering clicks after drag
const dragJustEndedRef = useRef<boolean>(false);
const dragEndTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Helper function to detect Safari browser
const isSafari = () => {
if (typeof window === 'undefined') return false;
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
};
// Keep clipSegmentsRef updated // Keep clipSegmentsRef updated
useEffect(() => { useEffect(() => {
@ -200,7 +203,17 @@ const TimelineControls = ({
setIsAutoSaving(true); setIsAutoSaving(true);
// Format segments data for API request - use ref to get latest segments and sort by start time // Format segments data for API request - use ref to get latest segments and sort by start time
// ONLY save chapters that have custom titles - filter out chapters without titles or with default names
const chapters = clipSegmentsRef.current const chapters = clipSegmentsRef.current
.filter((segment) => {
// Filter out empty titles
if (!segment.chapterTitle || !segment.chapterTitle.trim()) {
return false;
}
// Filter out default chapter names like "Chapter 1", "Chapter 2", etc.
const isDefaultName = /^Chapter \d+$/.test(segment.chapterTitle);
return !isDefaultName;
})
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically .sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
.map((chapter) => ({ .map((chapter) => ({
startTime: formatDetailedTime(chapter.startTime), startTime: formatDetailedTime(chapter.startTime),
@ -208,7 +221,7 @@ const TimelineControls = ({
chapterTitle: chapter.chapterTitle, chapterTitle: chapter.chapterTitle,
})); }));
logger.debug('chapters', chapters); logger.debug('Filtered chapters (only custom titles):', chapters);
const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null; const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
// For testing, use '1234' if no mediaId is available // For testing, use '1234' if no mediaId is available
@ -216,12 +229,13 @@ const TimelineControls = ({
logger.debug('mediaId', finalMediaId); logger.debug('mediaId', finalMediaId);
if (!finalMediaId || chapters.length === 0) { if (!finalMediaId) {
logger.debug('No mediaId or segments, skipping auto-save'); logger.debug('No mediaId, skipping auto-save');
setIsAutoSaving(false); setIsAutoSaving(false);
return; return;
} }
// Save chapters (empty array if no chapters have titles)
logger.debug('Auto-saving segments:', { mediaId: finalMediaId, chapters }); logger.debug('Auto-saving segments:', { mediaId: finalMediaId, chapters });
const response = await autoSaveVideo(finalMediaId, { chapters }); const response = await autoSaveVideo(finalMediaId, { chapters });
@ -277,8 +291,13 @@ const TimelineControls = ({
// Update editing title when selected segment changes // Update editing title when selected segment changes
useEffect(() => { useEffect(() => {
if (selectedSegment) { if (selectedSegment) {
// Always show the chapter title in the textarea, whether it's default or custom // Check if the chapter title is a default generated name (e.g., "Chapter 1", "Chapter 2", etc.)
setEditingChapterTitle(selectedSegment.chapterTitle || ''); const isDefaultChapterName = selectedSegment.chapterTitle &&
/^Chapter \d+$/.test(selectedSegment.chapterTitle);
// If it's a default name, show empty string so placeholder appears
// If it's a custom title, show the actual title
setEditingChapterTitle(isDefaultChapterName ? '' : (selectedSegment.chapterTitle || ''));
} else { } else {
setEditingChapterTitle(''); setEditingChapterTitle('');
} }
@ -503,11 +522,20 @@ const TimelineControls = ({
try { try {
// Format chapters data for API request - sort by start time first // Format chapters data for API request - sort by start time first
// ONLY save chapters that have custom titles - filter out chapters without titles or with default names
const chapters = clipSegments const chapters = clipSegments
.filter((segment) => segment.chapterTitle && segment.chapterTitle.trim()) .filter((segment) => {
// Filter out empty titles
if (!segment.chapterTitle || !segment.chapterTitle.trim()) {
return false;
}
// Filter out default chapter names like "Chapter 1", "Chapter 2", etc.
const isDefaultName = /^Chapter \d+$/.test(segment.chapterTitle);
return !isDefaultName;
})
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically .sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
.map((segment) => ({ .map((segment) => ({
chapterTitle: segment.chapterTitle || `Chapter ${segment.id}`, chapterTitle: segment.chapterTitle,
from: formatDetailedTime(segment.startTime), from: formatDetailedTime(segment.startTime),
to: formatDetailedTime(segment.endTime), to: formatDetailedTime(segment.endTime),
})); }));
@ -876,12 +904,6 @@ const TimelineControls = ({
logger.debug('Clearing auto-save timer in cleanup:', autoSaveTimerRef.current); logger.debug('Clearing auto-save timer in cleanup:', autoSaveTimerRef.current);
clearTimeout(autoSaveTimerRef.current); clearTimeout(autoSaveTimerRef.current);
} }
// Clear any pending drag end timeout
if (dragEndTimeoutRef.current) {
clearTimeout(dragEndTimeoutRef.current);
dragEndTimeoutRef.current = null;
}
}; };
}, [scheduleAutoSave]); }, [scheduleAutoSave]);
@ -1099,20 +1121,16 @@ const TimelineControls = ({
}; };
// Helper function to calculate available space for a new segment // Helper function to calculate available space for a new segment
const calculateAvailableSpace = (startTime: number, segmentsOverride?: Segment[]): number => { const calculateAvailableSpace = (startTime: number): number => {
// Always return at least 0.1 seconds to ensure tooltip shows // Always return at least 0.1 seconds to ensure tooltip shows
const MIN_SPACE = 0.1; const MIN_SPACE = 0.1;
// Use override segments if provided, otherwise use ref to get latest segments
// This ensures we always have the most up-to-date segments, especially important for Safari
const segmentsToUse = segmentsOverride || clipSegmentsRef.current;
// Determine the amount of available space: // Determine the amount of available space:
// 1. Check remaining space until the end of video // 1. Check remaining space until the end of video
const remainingDuration = Math.max(0, duration - startTime); const remainingDuration = Math.max(0, duration - startTime);
// 2. Find the next segment (if any) // 2. Find the next segment (if any)
const sortedSegments = [...segmentsToUse].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Find the next and previous segments // Find the next and previous segments
const nextSegment = sortedSegments.find((seg) => seg.startTime > startTime); const nextSegment = sortedSegments.find((seg) => seg.startTime > startTime);
@ -1128,6 +1146,14 @@ const TimelineControls = ({
availableSpace = duration - startTime; availableSpace = duration - startTime;
} }
// Log the space calculation for debugging
logger.debug('Space calculation:', {
position: formatDetailedTime(startTime),
nextSegment: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
prevSegment: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
availableSpace: formatDetailedTime(Math.max(MIN_SPACE, availableSpace)),
});
// Always return at least MIN_SPACE to ensure tooltip shows // Always return at least MIN_SPACE to ensure tooltip shows
return Math.max(MIN_SPACE, availableSpace); return Math.max(MIN_SPACE, availableSpace);
}; };
@ -1136,11 +1162,8 @@ const TimelineControls = ({
const updateTooltipForPosition = (currentPosition: number) => { const updateTooltipForPosition = (currentPosition: number) => {
if (!timelineRef.current) return; if (!timelineRef.current) return;
// Use ref to get latest segments to avoid stale state issues
const currentSegments = clipSegmentsRef.current;
// Find if we're in a segment at the current position with a small tolerance // Find if we're in a segment at the current position with a small tolerance
const segmentAtPosition = currentSegments.find((seg) => { const segmentAtPosition = clipSegments.find((seg) => {
const isWithinSegment = currentPosition >= seg.startTime && currentPosition <= seg.endTime; const isWithinSegment = currentPosition >= seg.startTime && currentPosition <= seg.endTime;
const isVeryCloseToStart = Math.abs(currentPosition - seg.startTime) < 0.001; const isVeryCloseToStart = Math.abs(currentPosition - seg.startTime) < 0.001;
const isVeryCloseToEnd = Math.abs(currentPosition - seg.endTime) < 0.001; const isVeryCloseToEnd = Math.abs(currentPosition - seg.endTime) < 0.001;
@ -1148,7 +1171,7 @@ const TimelineControls = ({
}); });
// Find the next and previous segments // Find the next and previous segments
const sortedSegments = [...currentSegments].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const nextSegment = sortedSegments.find((seg) => seg.startTime > currentPosition); const nextSegment = sortedSegments.find((seg) => seg.startTime > currentPosition);
const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < currentPosition); const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < currentPosition);
@ -1158,13 +1181,21 @@ const TimelineControls = ({
setShowEmptySpaceTooltip(false); setShowEmptySpaceTooltip(false);
} else { } else {
// We're in a cutaway area // We're in a cutaway area
// Calculate available space for new segment using current segments // Calculate available space for new segment
const availableSpace = calculateAvailableSpace(currentPosition, currentSegments); const availableSpace = calculateAvailableSpace(currentPosition);
setAvailableSegmentDuration(availableSpace); setAvailableSegmentDuration(availableSpace);
// Always show empty space tooltip // Always show empty space tooltip
setSelectedSegmentId(null); setSelectedSegmentId(null);
setShowEmptySpaceTooltip(true); setShowEmptySpaceTooltip(true);
// Log position info for debugging
logger.debug('Cutaway position:', {
current: formatDetailedTime(currentPosition),
prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
availableSpace: formatDetailedTime(availableSpace),
});
} }
// Update tooltip position // Update tooltip position
@ -1194,12 +1225,6 @@ const TimelineControls = ({
if (!timelineRef.current || !scrollContainerRef.current) return; if (!timelineRef.current || !scrollContainerRef.current) return;
// Safari-specific fix: Ignore clicks that happen immediately after a drag operation
// Safari fires click events after drag ends, which can cause issues with stale state
if (isSafari() && dragJustEndedRef.current) {
return;
}
// If on mobile device and video hasn't been initialized, don't handle timeline clicks // If on mobile device and video hasn't been initialized, don't handle timeline clicks
if (isIOSUninitialized) { if (isIOSUninitialized) {
return; return;
@ -1207,6 +1232,7 @@ const TimelineControls = ({
// Check if video is globally playing before the click // Check if video is globally playing before the click
const wasPlaying = videoRef.current && !videoRef.current.paused; const wasPlaying = videoRef.current && !videoRef.current.paused;
logger.debug('Video was playing before timeline click:', wasPlaying);
// Reset continuation flag when clicking on timeline - ensures proper boundary detection // Reset continuation flag when clicking on timeline - ensures proper boundary detection
setContinuePastBoundary(false); setContinuePastBoundary(false);
@ -1227,6 +1253,14 @@ const TimelineControls = ({
const newTime = position * duration; const newTime = position * duration;
// Log the position for debugging
logger.debug(
'Timeline clicked at:',
formatDetailedTime(newTime),
'distance from end:',
formatDetailedTime(duration - newTime)
);
// Store position globally for iOS Safari (this is critical for first-time visits) // Store position globally for iOS Safari (this is critical for first-time visits)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.lastSeekedPosition = newTime; window.lastSeekedPosition = newTime;
@ -1239,12 +1273,8 @@ const TimelineControls = ({
setClickedTime(newTime); setClickedTime(newTime);
setDisplayTime(newTime); setDisplayTime(newTime);
// Use ref to get latest segments to avoid stale state issues, especially in Safari
// Safari can fire click events immediately after drag before React re-renders
const currentSegments = clipSegmentsRef.current;
// Find if we clicked in a segment with a small tolerance for boundaries // Find if we clicked in a segment with a small tolerance for boundaries
const segmentAtClickedTime = currentSegments.find((seg) => { const segmentAtClickedTime = clipSegments.find((seg) => {
// Standard check for being inside a segment // Standard check for being inside a segment
const isInside = newTime >= seg.startTime && newTime <= seg.endTime; const isInside = newTime >= seg.startTime && newTime <= seg.endTime;
// Additional checks for being exactly at the start or end boundary (with small tolerance) // Additional checks for being exactly at the start or end boundary (with small tolerance)
@ -1265,7 +1295,7 @@ const TimelineControls = ({
if (isPlayingSegments && wasPlaying) { if (isPlayingSegments && wasPlaying) {
// Update the current segment index if we clicked into a segment // Update the current segment index if we clicked into a segment
if (segmentAtClickedTime) { if (segmentAtClickedTime) {
const orderedSegments = [...currentSegments].sort((a, b) => a.startTime - b.startTime); const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentAtClickedTime.id); const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentAtClickedTime.id);
if (targetSegmentIndex !== -1) { if (targetSegmentIndex !== -1) {
@ -1318,9 +1348,8 @@ const TimelineControls = ({
// We're in a cutaway area - always show tooltip // We're in a cutaway area - always show tooltip
setSelectedSegmentId(null); setSelectedSegmentId(null);
// Calculate the available space for a new segment using current segments from ref // Calculate the available space for a new segment
// This ensures we use the latest segments even if React hasn't re-rendered yet const availableSpace = calculateAvailableSpace(newTime);
const availableSpace = calculateAvailableSpace(newTime, currentSegments);
setAvailableSegmentDuration(availableSpace); setAvailableSegmentDuration(availableSpace);
// Calculate and set tooltip position correctly for zoomed timeline // Calculate and set tooltip position correctly for zoomed timeline
@ -1342,6 +1371,18 @@ const TimelineControls = ({
// Always show the empty space tooltip in cutaway areas // Always show the empty space tooltip in cutaway areas
setShowEmptySpaceTooltip(true); setShowEmptySpaceTooltip(true);
// Log the cutaway area details
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < newTime);
const nextSegment = sortedSegments.find((seg) => seg.startTime > newTime);
logger.debug('Clicked in cutaway area:', {
position: formatDetailedTime(newTime),
availableSpace: formatDetailedTime(availableSpace),
prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
});
} }
} }
}; };
@ -1494,10 +1535,6 @@ const TimelineControls = ({
return seg; return seg;
}); });
// Update the ref immediately during drag to ensure we always have latest segments
// This is critical for Safari which may fire events before React re-renders
clipSegmentsRef.current = updatedSegments;
// Create a custom event to update the segments WITHOUT recording in history during drag // Create a custom event to update the segments WITHOUT recording in history during drag
const updateEvent = new CustomEvent('update-segments', { const updateEvent = new CustomEvent('update-segments', {
detail: { detail: {
@ -1582,26 +1619,6 @@ const TimelineControls = ({
return seg; return seg;
}); });
// CRITICAL: Update the ref immediately with the new segments
// This ensures that if Safari fires a click event before React re-renders,
// the click handler will use the updated segments instead of stale ones
clipSegmentsRef.current = finalSegments;
// Safari-specific fix: Set flag to ignore clicks immediately after drag
// Safari fires click events after drag ends, which can interfere with state updates
if (isSafari()) {
dragJustEndedRef.current = true;
// Clear the flag after a delay to allow React to re-render with updated segments
// Increased timeout to ensure state has propagated
if (dragEndTimeoutRef.current) {
clearTimeout(dragEndTimeoutRef.current);
}
dragEndTimeoutRef.current = setTimeout(() => {
dragJustEndedRef.current = false;
dragEndTimeoutRef.current = null;
}, 200); // 200ms to ensure React has processed the state update and re-rendered
}
// Now we can create a history record for the complete drag operation // Now we can create a history record for the complete drag operation
const actionType = isLeft ? 'adjust_segment_start' : 'adjust_segment_end'; const actionType = isLeft ? 'adjust_segment_start' : 'adjust_segment_end';
document.dispatchEvent( document.dispatchEvent(
@ -1614,13 +1631,6 @@ const TimelineControls = ({
}) })
); );
// Dispatch segment-drag-end event for other listeners
document.dispatchEvent(
new CustomEvent('segment-drag-end', {
detail: { segmentId },
})
);
// After drag is complete, do a final check to see if playhead is inside the segment // After drag is complete, do a final check to see if playhead is inside the segment
if (selectedSegmentId === segmentId && videoRef.current) { if (selectedSegmentId === segmentId && videoRef.current) {
const currentTime = videoRef.current.currentTime; const currentTime = videoRef.current.currentTime;
@ -3970,7 +3980,9 @@ const TimelineControls = ({
<button <button
onClick={() => setShowSaveChaptersModal(true)} onClick={() => setShowSaveChaptersModal(true)}
className="save-chapters-button" className="save-chapters-button"
{...(clipSegments.length === 0 && { 'data-tooltip': 'Clear all chapters' })} data-tooltip={clipSegments.length === 0
? "Clear all chapters"
: "Save chapters"}
> >
{clipSegments.length === 0 {clipSegments.length === 0
? 'Clear Chapters' ? 'Clear Chapters'
@ -4107,4 +4119,4 @@ const TimelineControls = ({
); );
}; };
export default TimelineControls; export default TimelineControls;

View File

@ -20,7 +20,7 @@ const useVideoChapters = () => {
// Sort by start time to find chronological position // Sort by start time to find chronological position
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime); const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
// Find the index of our new segment // Find the index of our new segment
const chapterIndex = sortedSegments.findIndex((seg) => seg.startTime === newSegmentStartTime); const chapterIndex = sortedSegments.findIndex(seg => seg.startTime === newSegmentStartTime);
return `Chapter ${chapterIndex + 1}`; return `Chapter ${chapterIndex + 1}`;
}; };
@ -28,18 +28,12 @@ const useVideoChapters = () => {
const renumberAllSegments = (segments: Segment[]): Segment[] => { const renumberAllSegments = (segments: Segment[]): Segment[] => {
// Sort segments by start time // Sort segments by start time
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
// Renumber each segment based on its chronological position // Renumber each segment based on its chronological position
// Only update titles that follow the default "Chapter X" pattern to preserve custom titles return sortedSegments.map((segment, index) => ({
return sortedSegments.map((segment, index) => { ...segment,
const currentTitle = segment.chapterTitle || ''; chapterTitle: `Chapter ${index + 1}`
const isDefaultTitle = /^Chapter \d+$/.test(currentTitle); }));
return {
...segment,
chapterTitle: isDefaultTitle ? `Chapter ${index + 1}` : currentTitle,
};
});
}; };
// Helper function to parse time string (HH:MM:SS.mmm) to seconds // Helper function to parse time string (HH:MM:SS.mmm) to seconds
@ -60,9 +54,6 @@ const useVideoChapters = () => {
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
// Track if editor has been initialized to prevent re-initialization on Safari metadata events
const isInitializedRef = useRef<boolean>(false);
// Timeline state // Timeline state
const [trimStart, setTrimStart] = useState(0); const [trimStart, setTrimStart] = useState(0);
@ -111,7 +102,11 @@ const useVideoChapters = () => {
// Detect Safari browser // Detect Safari browser
const isSafari = () => { const isSafari = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera; const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent); const isSafariBrowser = /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
if (isSafariBrowser) {
logger.debug('Safari browser detected, enabling audio support fallbacks');
}
return isSafariBrowser;
}; };
// Initialize video event listeners // Initialize video event listeners
@ -120,15 +115,7 @@ const useVideoChapters = () => {
if (!video) return; if (!video) return;
const handleLoadedMetadata = () => { const handleLoadedMetadata = () => {
// CRITICAL: Prevent re-initialization if editor has already been initialized logger.debug('Video loadedmetadata event fired, duration:', video.duration);
// Safari fires loadedmetadata multiple times, which was resetting segments
if (isInitializedRef.current) {
// Still update duration and trimEnd in case they changed
setDuration(video.duration);
setTrimEnd(video.duration);
return;
}
setDuration(video.duration); setDuration(video.duration);
setTrimEnd(video.duration); setTrimEnd(video.duration);
@ -137,7 +124,9 @@ const useVideoChapters = () => {
let initialSegments: Segment[] = []; let initialSegments: Segment[] = [];
// Check if we have existing chapters from the backend // Check if we have existing chapters from the backend
const existingChapters = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || []; const existingChapters =
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) ||
[];
if (existingChapters.length > 0) { if (existingChapters.length > 0) {
// Create segments from existing chapters // Create segments from existing chapters
@ -161,7 +150,7 @@ const useVideoChapters = () => {
// Create a default segment that spans the entire video on first load // Create a default segment that spans the entire video on first load
const initialSegment: Segment = { const initialSegment: Segment = {
id: 1, id: 1,
chapterTitle: 'Chapter 1', chapterTitle: '',
startTime: 0, startTime: 0,
endTime: video.duration, endTime: video.duration,
}; };
@ -180,7 +169,7 @@ const useVideoChapters = () => {
setHistory([initialState]); setHistory([initialState]);
setHistoryPosition(0); setHistoryPosition(0);
setClipSegments(initialSegments); setClipSegments(initialSegments);
isInitializedRef.current = true; // Mark as initialized logger.debug('Editor initialized with segments:', initialSegments.length);
}; };
initializeEditor(); initializeEditor();
@ -188,18 +177,20 @@ const useVideoChapters = () => {
// Safari-specific fallback for audio files // Safari-specific fallback for audio files
const handleCanPlay = () => { const handleCanPlay = () => {
logger.debug('Video canplay event fired');
// If loadedmetadata hasn't fired yet but we have duration, trigger initialization // If loadedmetadata hasn't fired yet but we have duration, trigger initialization
// Also check if already initialized to prevent re-initialization if (video.duration && duration === 0) {
if (video.duration && duration === 0 && !isInitializedRef.current) { logger.debug('Safari fallback: Using canplay event to initialize');
handleLoadedMetadata(); handleLoadedMetadata();
} }
}; };
// Additional Safari fallback for audio files // Additional Safari fallback for audio files
const handleLoadedData = () => { const handleLoadedData = () => {
logger.debug('Video loadeddata event fired');
// If we still don't have duration, try again // If we still don't have duration, try again
// Also check if already initialized to prevent re-initialization if (video.duration && duration === 0) {
if (video.duration && duration === 0 && !isInitializedRef.current) { logger.debug('Safari fallback: Using loadeddata event to initialize');
handleLoadedMetadata(); handleLoadedMetadata();
} }
}; };
@ -231,12 +222,14 @@ const useVideoChapters = () => {
// Safari-specific fallback event listeners for audio files // Safari-specific fallback event listeners for audio files
if (isSafari()) { if (isSafari()) {
logger.debug('Adding Safari-specific event listeners for audio support');
video.addEventListener('canplay', handleCanPlay); video.addEventListener('canplay', handleCanPlay);
video.addEventListener('loadeddata', handleLoadedData); video.addEventListener('loadeddata', handleLoadedData);
// Additional timeout fallback for Safari audio files // Additional timeout fallback for Safari audio files
const safariTimeout = setTimeout(() => { const safariTimeout = setTimeout(() => {
if (video.duration && duration === 0 && !isInitializedRef.current) { if (video.duration && duration === 0) {
logger.debug('Safari timeout fallback: Force initializing editor');
handleLoadedMetadata(); handleLoadedMetadata();
} }
}, 1000); }, 1000);
@ -268,21 +261,21 @@ const useVideoChapters = () => {
useEffect(() => { useEffect(() => {
if (isSafari() && videoRef.current) { if (isSafari() && videoRef.current) {
const video = videoRef.current; const video = videoRef.current;
const initializeSafariOnInteraction = () => { const initializeSafariOnInteraction = () => {
// Try to load video metadata by attempting to play and immediately pause // Try to load video metadata by attempting to play and immediately pause
const attemptInitialization = async () => { const attemptInitialization = async () => {
try { try {
logger.debug('Safari: Attempting auto-initialization on user interaction'); logger.debug('Safari: Attempting auto-initialization on user interaction');
// Briefly play to trigger metadata loading, then pause // Briefly play to trigger metadata loading, then pause
await video.play(); await video.play();
video.pause(); video.pause();
// Check if we now have duration and initialize if needed // Check if we now have duration and initialize if needed
if (video.duration > 0 && clipSegments.length === 0) { if (video.duration > 0 && clipSegments.length === 0) {
logger.debug('Safari: Successfully initialized metadata, creating default segment'); logger.debug('Safari: Successfully initialized metadata, creating default segment');
const defaultSegment: Segment = { const defaultSegment: Segment = {
id: 1, id: 1,
chapterTitle: '', chapterTitle: '',
@ -293,14 +286,14 @@ const useVideoChapters = () => {
setDuration(video.duration); setDuration(video.duration);
setTrimEnd(video.duration); setTrimEnd(video.duration);
setClipSegments([defaultSegment]); setClipSegments([defaultSegment]);
const initialState: EditorState = { const initialState: EditorState = {
trimStart: 0, trimStart: 0,
trimEnd: video.duration, trimEnd: video.duration,
splitPoints: [], splitPoints: [],
clipSegments: [defaultSegment], clipSegments: [defaultSegment],
}; };
setHistory([initialState]); setHistory([initialState]);
setHistoryPosition(0); setHistoryPosition(0);
} }
@ -322,7 +315,7 @@ const useVideoChapters = () => {
// Add listeners for various user interactions // Add listeners for various user interactions
document.addEventListener('click', handleUserInteraction); document.addEventListener('click', handleUserInteraction);
document.addEventListener('keydown', handleUserInteraction); document.addEventListener('keydown', handleUserInteraction);
return () => { return () => {
document.removeEventListener('click', handleUserInteraction); document.removeEventListener('click', handleUserInteraction);
document.removeEventListener('keydown', handleUserInteraction); document.removeEventListener('keydown', handleUserInteraction);
@ -339,7 +332,7 @@ const useVideoChapters = () => {
// This play/pause will trigger metadata loading in Safari // This play/pause will trigger metadata loading in Safari
await video.play(); await video.play();
video.pause(); video.pause();
// The metadata events should fire now and initialize segments // The metadata events should fire now and initialize segments
return true; return true;
} catch (error) { } catch (error) {
@ -571,11 +564,8 @@ const useVideoChapters = () => {
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}` `Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}`
); );
// Renumber all segments to ensure proper chronological naming
const renumberedSegments = renumberAllSegments(e.detail.segments);
// Update segment state immediately for UI feedback // Update segment state immediately for UI feedback
setClipSegments(renumberedSegments); setClipSegments(e.detail.segments);
// Always save state to history for non-intermediate actions // Always save state to history for non-intermediate actions
if (isSignificantChange) { if (isSignificantChange) {
@ -583,7 +573,7 @@ const useVideoChapters = () => {
// ensure we capture the state properly // ensure we capture the state properly
setTimeout(() => { setTimeout(() => {
// Deep clone to ensure state is captured correctly // Deep clone to ensure state is captured correctly
const segmentsClone = JSON.parse(JSON.stringify(renumberedSegments)); const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments));
// Create a complete state snapshot // Create a complete state snapshot
const stateWithAction: EditorState = { const stateWithAction: EditorState = {
@ -929,10 +919,10 @@ const useVideoChapters = () => {
const singleChapter = backendChapters[0]; const singleChapter = backendChapters[0];
const startSeconds = parseTimeToSeconds(singleChapter.startTime); const startSeconds = parseTimeToSeconds(singleChapter.startTime);
const endSeconds = parseTimeToSeconds(singleChapter.endTime); const endSeconds = parseTimeToSeconds(singleChapter.endTime);
// Check if this single chapter spans the entire video (within 0.1 second tolerance) // Check if this single chapter spans the entire video (within 0.1 second tolerance)
const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1; const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1;
if (isFullVideoChapter) { if (isFullVideoChapter) {
logger.debug('Manual save: Single chapter spans full video - sending empty array'); logger.debug('Manual save: Single chapter spans full video - sending empty array');
backendChapters = []; backendChapters = [];

View File

@ -82,24 +82,27 @@
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--foreground, #333); color: var(--foreground, #333);
margin-bottom: 0.75rem; margin: 0;
} }
.save-chapters-button { .save-chapters-button {
color: #ffffff; display: flex;
background: #059669; align-items: center;
border-radius: 0.25rem; gap: 0.5rem;
font-size: 0.75rem; padding: 0.5rem 1rem;
padding: 0.25rem 0.5rem; background-color: #3b82f6;
cursor: pointer; color: white;
border: none; border: none;
white-space: nowrap; border-radius: 0.375rem;
transition: background-color 0.2s; font-size: 0.875rem;
min-width: fit-content; font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover { &:hover {
background-color: #059669; background-color: #2563eb;
box-shadow: 0 4px 6px -1px rgba(5, 150, 105, 0.3); transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
} }
&.has-changes { &.has-changes {
@ -202,9 +205,9 @@
} }
&.selected { &.selected {
border-color: #059669; border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
background-color: rgba(5, 150, 105, 0.05); background-color: rgba(59, 130, 246, 0.05);
} }
} }
@ -284,68 +287,29 @@
color: rgba(51, 51, 51, 0.7); color: rgba(51, 51, 51, 0.7);
} }
/* Generate 20 shades of #059669 (rgb(5, 150, 105)) */
/* Base color: #059669 = rgb(5, 150, 105) */
/* Creating variations from lighter to darker */
.segment-color-1 { .segment-color-1 {
background-color: rgba(167, 243, 208, 0.2); background-color: rgba(59, 130, 246, 0.15);
} }
.segment-color-2 { .segment-color-2 {
background-color: rgba(134, 239, 172, 0.2); background-color: rgba(16, 185, 129, 0.15);
} }
.segment-color-3 { .segment-color-3 {
background-color: rgba(101, 235, 136, 0.2); background-color: rgba(245, 158, 11, 0.15);
} }
.segment-color-4 { .segment-color-4 {
background-color: rgba(68, 231, 100, 0.2); background-color: rgba(239, 68, 68, 0.15);
} }
.segment-color-5 { .segment-color-5 {
background-color: rgba(35, 227, 64, 0.2); background-color: rgba(139, 92, 246, 0.15);
} }
.segment-color-6 { .segment-color-6 {
background-color: rgba(20, 207, 54, 0.2); background-color: rgba(236, 72, 153, 0.15);
} }
.segment-color-7 { .segment-color-7 {
background-color: rgba(15, 187, 48, 0.2); background-color: rgba(6, 182, 212, 0.15);
} }
.segment-color-8 { .segment-color-8 {
background-color: rgba(10, 167, 42, 0.2); background-color: rgba(250, 204, 21, 0.15);
}
.segment-color-9 {
background-color: rgba(5, 150, 105, 0.2);
}
.segment-color-10 {
background-color: rgba(4, 135, 95, 0.2);
}
.segment-color-11 {
background-color: rgba(3, 120, 85, 0.2);
}
.segment-color-12 {
background-color: rgba(2, 105, 75, 0.2);
}
.segment-color-13 {
background-color: rgba(2, 90, 65, 0.2);
}
.segment-color-14 {
background-color: rgba(1, 75, 55, 0.2);
}
.segment-color-15 {
background-color: rgba(1, 66, 48, 0.2);
}
.segment-color-16 {
background-color: rgba(1, 57, 41, 0.2);
}
.segment-color-17 {
background-color: rgba(1, 48, 34, 0.2);
}
.segment-color-18 {
background-color: rgba(0, 39, 27, 0.2);
}
.segment-color-19 {
background-color: rgba(0, 30, 20, 0.2);
}
.segment-color-20 {
background-color: rgba(0, 21, 13, 0.2);
} }
/* Responsive styles */ /* Responsive styles */

View File

@ -31,7 +31,7 @@
.ios-notification-icon { .ios-notification-icon {
flex-shrink: 0; flex-shrink: 0;
color: #059669; color: #0066cc;
margin-right: 15px; margin-right: 15px;
margin-top: 3px; margin-top: 3px;
} }
@ -96,7 +96,7 @@
} }
.ios-desktop-mode-btn { .ios-desktop-mode-btn {
background-color: #059669; background-color: #0066cc;
color: white; color: white;
border: none; border: none;
border-radius: 8px; border-radius: 8px;

View File

@ -92,12 +92,12 @@
} }
.modal-button-primary { .modal-button-primary {
background-color: #059669; background-color: #0066cc;
color: white; color: white;
} }
.modal-button-primary:hover { .modal-button-primary:hover {
background-color: #059669; background-color: #0055aa;
} }
.modal-button-secondary { .modal-button-secondary {
@ -138,7 +138,7 @@
.spinner { .spinner {
border: 4px solid rgba(0, 0, 0, 0.1); border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%; border-radius: 50%;
border-top: 4px solid #059669; border-top: 4px solid #0066cc;
width: 30px; width: 30px;
height: 30px; height: 30px;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
@ -224,7 +224,7 @@
padding: 12px 16px; padding: 12px 16px;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
background-color: #059669; background-color: #0066cc;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
@ -258,12 +258,12 @@
margin: 0 auto; margin: 0 auto;
width: auto; width: auto;
min-width: 220px; min-width: 220px;
background-color: #059669; background-color: #0066cc;
color: white; color: white;
} }
.centered-choice:hover { .centered-choice:hover {
background-color: #059669; background-color: #0055aa;
} }
@media (max-width: 480px) { @media (max-width: 480px) {
@ -300,7 +300,7 @@
.countdown { .countdown {
font-weight: bold; font-weight: bold;
color: #059669; color: #0066cc;
font-size: 1.1rem; font-size: 1.1rem;
} }
} }

View File

@ -1,16 +1,4 @@
#chapters-editor-root { #chapters-editor-root {
.timeline-header-container {
margin-left: 1rem;
margin-top: -0.5rem;
}
.timeline-header-title {
font-size: 1.125rem;
font-weight: 600;
color: #059669;
margin: 0;
}
.timeline-container-card { .timeline-container-card {
background-color: white; background-color: white;
border-radius: 0.5rem; border-radius: 0.5rem;
@ -23,8 +11,6 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding-bottom: 0.5rem;
border-bottom: 2px solid rgba(16, 185, 129, 0.2);
} }
.timeline-title { .timeline-title {
@ -34,7 +20,7 @@
} }
.timeline-title-text { .timeline-title-text {
font-size: 0.875rem; font-weight: 700;
} }
.current-time { .current-time {
@ -62,11 +48,10 @@
.timeline-container { .timeline-container {
position: relative; position: relative;
min-width: 100%; min-width: 100%;
background-color: #e2ede4; background-color: #fafbfc;
height: 70px; height: 70px;
border-radius: 0.25rem; border-radius: 0.25rem;
overflow: visible !important; overflow: visible !important;
border: 1px solid rgba(16, 185, 129, 0.2);
} }
.timeline-marker { .timeline-marker {
@ -209,7 +194,7 @@
left: 0; left: 0;
right: 0; right: 0;
padding: 0.4rem; padding: 0.4rem;
background-color: rgba(16, 185, 129, 0.6); background-color: rgba(0, 0, 0, 0.4);
color: white; color: white;
opacity: 1; opacity: 1;
transition: background-color 0.2s; transition: background-color 0.2s;
@ -217,15 +202,15 @@
} }
.clip-segment:hover .clip-segment-info { .clip-segment:hover .clip-segment-info {
background-color: rgba(16, 185, 129, 0.7); background-color: rgba(0, 0, 0, 0.5);
} }
.clip-segment.selected .clip-segment-info { .clip-segment.selected .clip-segment-info {
background-color: rgba(5, 150, 105, 0.8); background-color: rgba(59, 130, 246, 0.5);
} }
.clip-segment.selected:hover .clip-segment-info { .clip-segment.selected:hover .clip-segment-info {
background-color: rgba(5, 150, 105, 0.75); background-color: rgba(59, 130, 246, 0.4);
} }
.clip-segment-name { .clip-segment-name {
@ -555,7 +540,7 @@
.save-copy-button, .save-copy-button,
.save-segments-button { .save-segments-button {
color: #ffffff; color: #ffffff;
background: #059669; background: #0066cc;
border-radius: 0.25rem; border-radius: 0.25rem;
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
@ -728,7 +713,7 @@
height: 50px; height: 50px;
border: 5px solid rgba(0, 0, 0, 0.1); border: 5px solid rgba(0, 0, 0, 0.1);
border-radius: 50%; border-radius: 50%;
border-top-color: #059669; border-top-color: #0066cc;
animation: spin 1s ease-in-out infinite; animation: spin 1s ease-in-out infinite;
} }
@ -768,7 +753,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.75rem 1.25rem; padding: 0.75rem 1.25rem;
background-color: #059669; background-color: #0066cc;
color: white; color: white;
border-radius: 4px; border-radius: 4px;
text-decoration: none; text-decoration: none;
@ -781,7 +766,7 @@
} }
.modal-choice-button:hover { .modal-choice-button:hover {
background-color:rgb(7, 119, 84); background-color: #0056b3;
} }
.modal-choice-button svg { .modal-choice-button svg {
@ -956,6 +941,7 @@
.save-chapters-button:hover { .save-chapters-button:hover {
background-color: #2563eb; background-color: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3); box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
} }

View File

@ -309,11 +309,6 @@ const App = () => {
canRedo={historyPosition < history.length - 1} canRedo={historyPosition < history.length - 1}
/> />
{/* Timeline Header */}
<div className="timeline-header-container">
<h2 className="timeline-header-title">Trim or Split</h2>
</div>
{/* Timeline Controls */} {/* Timeline Controls */}
<TimelineControls <TimelineControls
currentTime={currentTime} currentTime={currentTime}

View File

@ -28,9 +28,9 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
// Generate the same color background for a segment as shown in the timeline // Generate the same color background for a segment as shown in the timeline
const getSegmentColorClass = (index: number) => { const getSegmentColorClass = (index: number) => {
// Return CSS class based on index modulo 20 // Return CSS class based on index modulo 8
// This matches the CSS classes for up to 20 segments // This matches the CSS nth-child selectors in the timeline
return `segment-default-color segment-color-${(index % 20) + 1}`; return `segment-default-color segment-color-${(index % 8) + 1}`;
}; };
return ( return (

View File

@ -99,7 +99,6 @@
} }
.segment-thumbnail { .segment-thumbnail {
display: none;
width: 4rem; width: 4rem;
height: 2.25rem; height: 2.25rem;
background-size: cover; background-size: cover;
@ -130,7 +129,7 @@
margin-top: 0.25rem; margin-top: 0.25rem;
display: inline-block; display: inline-block;
background-color: #f3f4f6; background-color: #f3f4f6;
padding: 0; padding: 0 0.5rem;
border-radius: 0.25rem; border-radius: 0.25rem;
color: black; color: black;
} }
@ -170,67 +169,28 @@
color: rgba(51, 51, 51, 0.7); color: rgba(51, 51, 51, 0.7);
} }
/* Generate 20 shades of #2563eb (rgb(37, 99, 235)) */
/* Base color: #2563eb = rgb(37, 99, 235) */
/* Creating variations from lighter to darker */
.segment-color-1 { .segment-color-1 {
background-color: rgba(147, 179, 247, 0.2); background-color: rgba(59, 130, 246, 0.15);
} }
.segment-color-2 { .segment-color-2 {
background-color: rgba(129, 161, 243, 0.2); background-color: rgba(16, 185, 129, 0.15);
} }
.segment-color-3 { .segment-color-3 {
background-color: rgba(111, 143, 239, 0.2); background-color: rgba(245, 158, 11, 0.15);
} }
.segment-color-4 { .segment-color-4 {
background-color: rgba(93, 125, 237, 0.2); background-color: rgba(239, 68, 68, 0.15);
} }
.segment-color-5 { .segment-color-5 {
background-color: rgba(75, 107, 235, 0.2); background-color: rgba(139, 92, 246, 0.15);
} }
.segment-color-6 { .segment-color-6 {
background-color: rgba(65, 99, 235, 0.2); background-color: rgba(236, 72, 153, 0.15);
} }
.segment-color-7 { .segment-color-7 {
background-color: rgba(55, 91, 235, 0.2); background-color: rgba(6, 182, 212, 0.15);
} }
.segment-color-8 { .segment-color-8 {
background-color: rgba(45, 83, 235, 0.2); background-color: rgba(250, 204, 21, 0.15);
}
.segment-color-9 {
background-color: rgba(37, 99, 235, 0.2);
}
.segment-color-10 {
background-color: rgba(33, 89, 215, 0.2);
}
.segment-color-11 {
background-color: rgba(29, 79, 195, 0.2);
}
.segment-color-12 {
background-color: rgba(25, 69, 175, 0.2);
}
.segment-color-13 {
background-color: rgba(21, 59, 155, 0.2);
}
.segment-color-14 {
background-color: rgba(17, 49, 135, 0.2);
}
.segment-color-15 {
background-color: rgba(15, 43, 119, 0.2);
}
.segment-color-16 {
background-color: rgba(13, 37, 103, 0.2);
}
.segment-color-17 {
background-color: rgba(11, 31, 87, 0.2);
}
.segment-color-18 {
background-color: rgba(9, 25, 71, 0.2);
}
.segment-color-19 {
background-color: rgba(7, 19, 55, 0.2);
}
.segment-color-20 {
background-color: rgba(5, 13, 39, 0.2);
} }
} }

View File

@ -1,16 +1,4 @@
#video-editor-trim-root { #video-editor-trim-root {
.timeline-header-container {
margin-left: 1rem;
margin-top: -0.5rem;
}
.timeline-header-title {
font-size: 1.125rem;
font-weight: 600;
color: #2563eb;
margin: 0;
}
.timeline-container-card { .timeline-container-card {
background-color: white; background-color: white;
border-radius: 0.5rem; border-radius: 0.5rem;
@ -23,8 +11,6 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding-bottom: 0.5rem;
border-bottom: 2px solid rgba(59, 130, 246, 0.2);
} }
.timeline-title { .timeline-title {
@ -34,7 +20,7 @@
} }
.timeline-title-text { .timeline-title-text {
font-size: 0.875rem; font-weight: 700;
} }
.current-time { .current-time {
@ -62,11 +48,10 @@
.timeline-container { .timeline-container {
position: relative; position: relative;
min-width: 100%; min-width: 100%;
background-color: #eff6ff; background-color: #fafbfc;
height: 70px; height: 70px;
border-radius: 0.25rem; border-radius: 0.25rem;
overflow: visible !important; overflow: visible !important;
border: 1px solid rgba(59, 130, 246, 0.2);
} }
.timeline-marker { .timeline-marker {
@ -209,7 +194,7 @@
left: 0; left: 0;
right: 0; right: 0;
padding: 0.4rem; padding: 0.4rem;
background-color: rgba(59, 130, 246, 0.6); background-color: rgba(0, 0, 0, 0.4);
color: white; color: white;
opacity: 1; opacity: 1;
transition: background-color 0.2s; transition: background-color 0.2s;
@ -217,15 +202,15 @@
} }
.clip-segment:hover .clip-segment-info { .clip-segment:hover .clip-segment-info {
background-color: rgba(59, 130, 246, 0.7); background-color: rgba(0, 0, 0, 0.5);
} }
.clip-segment.selected .clip-segment-info { .clip-segment.selected .clip-segment-info {
background-color: rgba(37, 99, 235, 0.8); background-color: rgba(59, 130, 246, 0.5);
} }
.clip-segment.selected:hover .clip-segment-info { .clip-segment.selected:hover .clip-segment-info {
background-color: rgba(37, 99, 235, 0.75); background-color: rgba(59, 130, 246, 0.4);
} }
.clip-segment-name { .clip-segment-name {

View File

@ -1,28 +1,12 @@
{ {
"presets": [ "presets": [
"@babel/react", "@babel/react", ["@babel/env", {
[ "modules": false,
"@babel/env", "useBuiltIns": "usage",
{ "corejs": 3,
"modules": false, "targets": {
"useBuiltIns": "usage", "browsers": ["defaults"]
"corejs": 3, }
"targets": { }]
"browsers": ["defaults"] ]
} }
}
]
],
"env": {
"test": {
"presets": [
[
"@babel/env",
{
"targets": { "node": "current" }
}
]
]
}
}
}

View File

@ -27,39 +27,3 @@ Open in browser: [http://localhost:8088](http://localhost:8088)
Generates the folder "**_frontend/dist_**". Generates the folder "**_frontend/dist_**".
Copy folders and files from "**_frontend/dist/static_**" into "**_static_**". Copy folders and files from "**_frontend/dist/static_**" into "**_static_**".
---
### Test Scripts
#### test
Run all unit tests once.
```sh
npm run test
```
#### test-watch
Run tests in watch mode for development.
```sh
npm run test-watch
```
#### test-coverage
Run tests with coverage reporting in `./coverage` folder.
```sh
npm run test-coverage
```
#### test-coverage-watch
Run tests with coverage in watch mode.
```sh
npm run test-coverage-watch
```

View File

@ -1,9 +0,0 @@
/** @type {import("jest").Config} **/
module.exports = {
testEnvironment: 'jsdom',
transform: {
'^.+\\.tsx?$': 'ts-jest',
'^.+\\.jsx?$': 'babel-jest',
},
collectCoverageFrom: ['src/**'],
};

View File

@ -1,69 +1,57 @@
{ {
"name": "mediacms-frontend", "name": "mediacms-frontend",
"version": "0.9.2", "version": "0.9.1",
"description": "", "description": "",
"author": "", "author": "",
"license": "", "license": "",
"keywords": [], "keywords": [],
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "mediacms-scripts development --config=./config/mediacms.config.js --host=0.0.0.0 --port=8088", "start": "mediacms-scripts development --config=./config/mediacms.config.js --host=0.0.0.0 --port=8088",
"dist": "mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist", "dist": "mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist"
"test": "jest", },
"test-coverage": "npx rimraf ./coverage && jest --coverage", "browserslist": [
"test-coverage-watch": "npm run test-coverage -- --watchAll", "cover 99.5%"
"test-watch": "jest --watch" ],
}, "devDependencies": {
"browserslist": [ "@babel/core": "^7.26.9",
"cover 99.5%" "@babel/preset-env": "^7.26.9",
], "@babel/preset-react": "^7.26.3",
"devDependencies": { "@types/minimatch": "^5.1.2",
"@babel/core": "^7.26.9", "@types/react": "^19.0.10",
"@babel/preset-env": "^7.26.9", "@types/react-dom": "^19.0.4",
"@babel/preset-react": "^7.26.3", "autoprefixer": "^10.4.21",
"@types/flux": "^3.1.15", "babel-loader": "^10.0.0",
"@types/jest": "^29.5.12", "compass-mixins": "^0.12.12",
"@types/minimatch": "^5.1.2", "copy-webpack-plugin": "^13.0.0",
"@types/react": "^19.0.10", "core-js": "^3.41.0",
"@types/react-dom": "^19.0.4", "css-loader": "^7.1.2",
"@types/url-parse": "^1.4.11", "dotenv": "^16.4.7",
"autoprefixer": "^10.4.21", "ejs": "^3.1.10",
"babel-jest": "^30.2.0", "ejs-compiled-loader": "^3.1.0",
"babel-loader": "^10.0.0", "mediacms-scripts": "file:packages/scripts",
"compass-mixins": "^0.12.12", "postcss-loader": "^8.1.1",
"copy-webpack-plugin": "^13.0.0", "prettier": "^3.5.3",
"core-js": "^3.41.0", "prop-types": "^15.8.1",
"css-loader": "^7.1.2", "sass": "^1.85.1",
"dotenv": "^16.4.7", "sass-loader": "^16.0.5",
"ejs": "^3.1.10", "ts-loader": "^9.5.2",
"ejs-compiled-loader": "^3.1.0", "typescript": "^5.8.2",
"jest": "^29.7.0", "url-loader": "^4.1.1",
"jest-environment-jsdom": "^30.2.0", "webpack": "^5.98.0"
"jsdom": "^27.3.0", },
"mediacms-scripts": "file:packages/scripts", "dependencies": {
"postcss-loader": "^8.1.1", "@react-pdf-viewer/core": "^3.9.0",
"prettier": "^3.5.3", "@react-pdf-viewer/default-layout": "^3.9.0",
"prop-types": "^15.8.1", "axios": "^1.8.2",
"sass": "^1.85.1", "flux": "^4.0.4",
"sass-loader": "^16.0.5", "normalize.css": "^8.0.1",
"ts-jest": "^29.2.5", "pdfjs-dist": "3.4.120",
"ts-loader": "^9.5.2", "react": "^17.0.2",
"typescript": "^5.9.3", "react-dom": "^17.0.2",
"url-loader": "^4.1.1", "react-mentions": "^4.3.1",
"webpack": "^5.98.0" "sortablejs": "^1.13.0",
}, "timeago.js": "^4.0.2",
"dependencies": { "url-parse": "^1.5.10"
"@react-pdf-viewer/core": "^3.9.0", }
"@react-pdf-viewer/default-layout": "^3.9.0",
"axios": "^1.8.2",
"flux": "^4.0.4",
"normalize.css": "^8.0.1",
"pdfjs-dist": "3.4.120",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-mentions": "^4.3.1",
"sortablejs": "^1.13.0",
"timeago.js": "^4.0.2",
"url-parse": "^1.5.10"
}
} }

View File

@ -9,29 +9,10 @@
.bulk-actions-container { .bulk-actions-container {
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
gap: 20px; gap: 20px;
margin-bottom: 16px; margin-bottom: 16px;
.add-media-button {
margin-left: auto;
a {
display: flex;
align-items: center;
}
.circle-icon-button {
width: 48px;
height: 48px;
.material-icons {
font-size: 32px;
}
}
}
} }
} }

View File

@ -2,9 +2,6 @@ import React from 'react';
import { MediaListRow } from './MediaListRow'; import { MediaListRow } from './MediaListRow';
import { BulkActionsDropdown } from './BulkActionsDropdown'; import { BulkActionsDropdown } from './BulkActionsDropdown';
import { SelectAllCheckbox } from './SelectAllCheckbox'; import { SelectAllCheckbox } from './SelectAllCheckbox';
import { CircleIconButton, MaterialIcon } from './_shared';
import { LinksConsumer } from '../utils/contexts';
import { translateString } from '../utils/helpers/';
import './MediaListWrapper.scss'; import './MediaListWrapper.scss';
interface MediaListWrapperProps { interface MediaListWrapperProps {
@ -20,7 +17,6 @@ interface MediaListWrapperProps {
onBulkAction?: (action: string) => void; onBulkAction?: (action: string) => void;
onSelectAll?: () => void; onSelectAll?: () => void;
onDeselectAll?: () => void; onDeselectAll?: () => void;
showAddMediaButton?: boolean;
} }
export const MediaListWrapper: React.FC<MediaListWrapperProps> = ({ export const MediaListWrapper: React.FC<MediaListWrapperProps> = ({
@ -36,35 +32,19 @@ export const MediaListWrapper: React.FC<MediaListWrapperProps> = ({
onBulkAction = () => {}, onBulkAction = () => {},
onSelectAll = () => {}, onSelectAll = () => {},
onDeselectAll = () => {}, onDeselectAll = () => {},
showAddMediaButton = false,
}) => ( }) => (
<div className={(className ? className + ' ' : '') + 'media-list-wrapper'} style={style}> <div className={(className ? className + ' ' : '') + 'media-list-wrapper'} style={style}>
<MediaListRow title={title} viewAllLink={viewAllLink} viewAllText={viewAllText}> <MediaListRow title={title} viewAllLink={viewAllLink} viewAllText={viewAllText}>
{showBulkActions && ( {showBulkActions && (
<LinksConsumer> <div className="bulk-actions-container">
{(links) => ( <BulkActionsDropdown selectedCount={selectedCount} onActionSelect={onBulkAction} />
<div className="bulk-actions-container"> <SelectAllCheckbox
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}> totalCount={totalCount}
<BulkActionsDropdown selectedCount={selectedCount} onActionSelect={onBulkAction} /> selectedCount={selectedCount}
<SelectAllCheckbox onSelectAll={onSelectAll}
totalCount={totalCount} onDeselectAll={onDeselectAll}
selectedCount={selectedCount} />
onSelectAll={onSelectAll} </div>
onDeselectAll={onDeselectAll}
/>
</div>
{showAddMediaButton && (
<div className="add-media-button">
<a href={links.user.addMedia} title={translateString('Add media')}>
<CircleIconButton>
<MaterialIcon type="video_call" />
</CircleIconButton>
</a>
</div>
)}
</div>
)}
</LinksConsumer>
)} )}
{children || null} {children || null}
</MediaListRow> </MediaListRow>

View File

@ -53,9 +53,9 @@ export function PlaylistItem(props) {
<UnderThumbWrapper title={props.title} link={props.link}> <UnderThumbWrapper title={props.title} link={props.link}>
{titleComponent()} {titleComponent()}
{metaComponents()} {metaComponents()}
<span className="view-full-playlist"> <a href={props.link} title="" className="view-full-playlist">
VIEW FULL PLAYLIST VIEW FULL PLAYLIST
</span> </a>
</UnderThumbWrapper> </UnderThumbWrapper>
</div> </div>
</div> </div>

View File

@ -21,16 +21,12 @@ function downloadOptionsList() {
for (g in encodings_info[k]) { for (g in encodings_info[k]) {
if (encodings_info[k].hasOwnProperty(g)) { if (encodings_info[k].hasOwnProperty(g)) {
if ('success' === encodings_info[k][g].status && 100 === encodings_info[k][g].progress && null !== encodings_info[k][g].url) { if ('success' === encodings_info[k][g].status && 100 === encodings_info[k][g].progress && null !== encodings_info[k][g].url) {
// Use original media URL for download instead of encoded version
const originalUrl = media_data.original_media_url;
const originalFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : media_data.title;
optionsList[encodings_info[k][g].title] = { optionsList[encodings_info[k][g].title] = {
text: k + ' - ' + g.toUpperCase() + ' (' + encodings_info[k][g].size + ')', text: k + ' - ' + g.toUpperCase() + ' (' + encodings_info[k][g].size + ')',
link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url), link: formatInnerLink(encodings_info[k][g].url, SiteContext._currentValue.url),
linkAttr: { linkAttr: {
target: '_blank', target: '_blank',
download: originalFilename, download: media_data.title + '_' + k + '_' + g.toUpperCase(),
}, },
}; };
} }
@ -40,16 +36,12 @@ function downloadOptionsList() {
} }
} }
// Extract actual filename from the original media URL
const originalUrl = media_data.original_media_url;
const originalFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : media_data.title;
optionsList.original_media_url = { optionsList.original_media_url = {
text: 'Original file (' + media_data.size + ')', text: 'Original file (' + media_data.size + ')',
link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url), link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url),
linkAttr: { linkAttr: {
target: '_blank', target: '_blank',
download: originalFilename, download: media_data.title,
}, },
}; };

File diff suppressed because it is too large Load Diff

View File

@ -3,278 +3,257 @@ import { SiteContext } from '../../utils/contexts/';
import { useUser, usePopup } from '../../utils/hooks/'; import { useUser, usePopup } from '../../utils/hooks/';
import { PageStore, MediaPageStore } from '../../utils/stores/'; import { PageStore, MediaPageStore } from '../../utils/stores/';
import { PageActions, MediaPageActions } from '../../utils/actions/'; import { PageActions, MediaPageActions } from '../../utils/actions/';
import { formatInnerLink, inEmbeddedApp, publishedOnDate } from '../../utils/helpers/'; import { formatInnerLink, publishedOnDate } from '../../utils/helpers/';
import { PopupMain } from '../_shared/'; import { PopupMain } from '../_shared/';
import CommentsList from '../comments/Comments'; import CommentsList from '../comments/Comments';
import { replaceString } from '../../utils/helpers/'; import { replaceString } from '../../utils/helpers/';
import { translateString } from '../../utils/helpers/'; import { translateString } from '../../utils/helpers/';
function metafield(arr) { function metafield(arr) {
let i; let i;
let sep; let sep;
let ret = []; let ret = [];
if (arr && arr.length) { if (arr && arr.length) {
i = 0; i = 0;
sep = 1 < arr.length ? ', ' : ''; sep = 1 < arr.length ? ', ' : '';
while (i < arr.length) { while (i < arr.length) {
ret[i] = ( ret[i] = (
<div key={i}> <div key={i}>
<a href={arr[i].url} title={arr[i].title}> <a href={arr[i].url} title={arr[i].title}>
{arr[i].title} {arr[i].title}
</a> </a>
{i < arr.length - 1 ? sep : ''} {i < arr.length - 1 ? sep : ''}
</div> </div>
); );
i += 1; i += 1;
}
} }
}
return ret; return ret;
} }
function MediaAuthorBanner(props) { function MediaAuthorBanner(props) {
return ( return (
<div className="media-author-banner"> <div className="media-author-banner">
<div> <div>
<a className="author-banner-thumb" href={props.link || null} title={props.name}> <a className="author-banner-thumb" href={props.link || null} title={props.name}>
<span style={{ backgroundImage: 'url(' + props.thumb + ')' }}> <span style={{ backgroundImage: 'url(' + props.thumb + ')' }}>
<img src={props.thumb} loading="lazy" alt={props.name} title={props.name} /> <img src={props.thumb} loading="lazy" alt={props.name} title={props.name} />
</span> </span>
</a> </a>
</div> </div>
<div> <div>
<span> <span>
<a href={props.link} className="author-banner-name" title={props.name}> <a href={props.link} className="author-banner-name" title={props.name}>
<span>{props.name}</span> <span>{props.name}</span>
</a> </a>
</span> </span>
{PageStore.get('config-media-item').displayPublishDate && props.published ? ( {PageStore.get('config-media-item').displayPublishDate && props.published ? (
<span className="author-banner-date"> <span className="author-banner-date">
{translateString('Published on')} {replaceString(publishedOnDate(new Date(props.published)))} {translateString('Published on')} {replaceString(publishedOnDate(new Date(props.published)))}
</span> </span>
) : null} ) : null}
</div> </div>
</div> </div>
); );
} }
function MediaMetaField(props) { function MediaMetaField(props) {
return ( return (
<div className={props.id.trim() ? 'media-content-' + props.id.trim() : null}> <div className={props.id.trim() ? 'media-content-' + props.id.trim() : null}>
<div className="media-content-field"> <div className="media-content-field">
<div className="media-content-field-label"> <div className="media-content-field-label">
<h4>{props.title}</h4> <h4>{props.title}</h4>
</div>
<div className="media-content-field-content">{props.value}</div>
</div>
</div> </div>
); <div className="media-content-field-content">{props.value}</div>
</div>
</div>
);
} }
function EditMediaButton(props) { function EditMediaButton(props) {
let link = props.link; let link = props.link;
if (window.MediaCMS.site.devEnv) { if (window.MediaCMS.site.devEnv) {
link = '/edit-media.html'; link = '/edit-media.html';
} }
return ( return (
<a href={link} rel="nofollow" title={translateString('Edit media')} className="edit-media-icon"> <a href={link} rel="nofollow" title={translateString('Edit media')} className="edit-media-icon">
<i className="material-icons">edit</i> <i className="material-icons">edit</i>
</a> </a>
); );
} }
export default function ViewerInfoContent(props) { export default function ViewerInfoContent(props) {
const { userCan } = useUser(); const { userCan } = useUser();
const description = props.description.trim(); const description = props.description.trim();
const tagsContent = const tagsContent =
!PageStore.get('config-enabled').taxonomies.tags || PageStore.get('config-enabled').taxonomies.tags.enabled !PageStore.get('config-enabled').taxonomies.tags || PageStore.get('config-enabled').taxonomies.tags.enabled
? metafield(MediaPageStore.get('media-tags')) ? metafield(MediaPageStore.get('media-tags'))
: []; : [];
const categoriesContent = PageStore.get('config-options').pages.media.categoriesWithTitle const categoriesContent = PageStore.get('config-options').pages.media.categoriesWithTitle
? [] ? []
: !PageStore.get('config-enabled').taxonomies.categories || : !PageStore.get('config-enabled').taxonomies.categories ||
PageStore.get('config-enabled').taxonomies.categories.enabled PageStore.get('config-enabled').taxonomies.categories.enabled
? metafield(MediaPageStore.get('media-categories')) ? metafield(MediaPageStore.get('media-categories'))
: []; : [];
let summary = MediaPageStore.get('media-summary'); let summary = MediaPageStore.get('media-summary');
summary = summary ? summary.trim() : ''; summary = summary ? summary.trim() : '';
const [popupContentRef, PopupContent, PopupTrigger] = usePopup(); const [popupContentRef, PopupContent, PopupTrigger] = usePopup();
const [hasSummary, setHasSummary] = useState('' !== summary); const [hasSummary, setHasSummary] = useState('' !== summary);
const [isContentVisible, setIsContentVisible] = useState('' == summary); const [isContentVisible, setIsContentVisible] = useState('' == summary);
function proceedMediaRemoval() { function proceedMediaRemoval() {
MediaPageActions.removeMedia(); MediaPageActions.removeMedia();
popupContentRef.current.toggle(); popupContentRef.current.toggle();
}
function cancelMediaRemoval() {
popupContentRef.current.toggle();
}
function onMediaDelete(mediaId) {
// FIXME: Without delay creates conflict [ Uncaught Error: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. ].
setTimeout(function () {
PageActions.addNotification('Media removed. Redirecting...', 'mediaDelete');
setTimeout(function () {
window.location.href =
SiteContext._currentValue.url + '/' + MediaPageStore.get('media-data').author_profile.replace(/^\//g, '');
}, 2000);
}, 100);
if (void 0 !== mediaId) {
console.info("Removed media '" + mediaId + '"');
}
}
function onMediaDeleteFail(mediaId) {
// FIXME: Without delay creates conflict [ Uncaught Error: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. ].
setTimeout(function () {
PageActions.addNotification('Media removal failed', 'mediaDeleteFail');
}, 100);
if (void 0 !== mediaId) {
console.info('Media "' + mediaId + '"' + ' removal failed');
}
}
function onClickLoadMore() {
setIsContentVisible(!isContentVisible);
}
useEffect(() => {
MediaPageStore.on('media_delete', onMediaDelete);
MediaPageStore.on('media_delete_fail', onMediaDeleteFail);
return () => {
MediaPageStore.removeListener('media_delete', onMediaDelete);
MediaPageStore.removeListener('media_delete_fail', onMediaDeleteFail);
};
}, []);
const authorLink = formatInnerLink(props.author.url, SiteContext._currentValue.url);
const authorThumb = formatInnerLink(props.author.thumb, SiteContext._currentValue.url);
function setTimestampAnchors(text) {
function wrapTimestampWithAnchor(match, string) {
let split = match.split(':'),
s = 0,
m = 1;
while (split.length > 0) {
s += m * parseInt(split.pop(), 10);
m *= 60;
}
const wrapped = `<a href="#" data-timestamp="${s}" class="video-timestamp">${match}</a>`;
return wrapped;
} }
function cancelMediaRemoval() { const timeRegex = new RegExp('((\\d)?\\d:)?(\\d)?\\d:\\d\\d', 'g');
popupContentRef.current.toggle(); return text.replace(timeRegex, wrapTimestampWithAnchor);
} }
function onMediaDelete(mediaId) { return (
// FIXME: Without delay creates conflict [ Uncaught Error: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. ]. <div className="media-info-content">
setTimeout(function () { {void 0 === PageStore.get('config-media-item').displayAuthor ||
PageActions.addNotification('Media removed. Redirecting...', 'mediaDelete'); null === PageStore.get('config-media-item').displayAuthor ||
setTimeout(function () { !!PageStore.get('config-media-item').displayAuthor ? (
window.location.href = <MediaAuthorBanner link={authorLink} thumb={authorThumb} name={props.author.name} published={props.published} />
SiteContext._currentValue.url + ) : null}
'/' +
MediaPageStore.get('media-data').author_profile.replace(/^\//g, '');
}, 2000);
}, 100);
if (void 0 !== mediaId) { <div className="media-content-banner">
console.info("Removed media '" + mediaId + '"'); <div className="media-content-banner-inner">
} {hasSummary ? <div className="media-content-summary">{summary}</div> : null}
} {(!hasSummary || isContentVisible) && description ? (
<div
className="media-content-description"
dangerouslySetInnerHTML={{ __html: setTimestampAnchors(description) }}
></div>
) : null}
{hasSummary ? (
<button className="load-more" onClick={onClickLoadMore}>
{isContentVisible ? 'SHOW LESS' : 'SHOW MORE'}
</button>
) : null}
{tagsContent.length ? (
<MediaMetaField
value={tagsContent}
title={1 < tagsContent.length ? translateString('Tags') : translateString('Tag')}
id="tags"
/>
) : null}
{categoriesContent.length ? (
<MediaMetaField
value={categoriesContent}
title={1 < categoriesContent.length ? translateString('Categories') : translateString('Category')}
id="categories"
/>
) : null}
function onMediaDeleteFail(mediaId) { {userCan.editMedia ? (
// FIXME: Without delay creates conflict [ Uncaught Error: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. ]. <div className="media-author-actions">
setTimeout(function () { {userCan.editMedia ? <EditMediaButton link={MediaPageStore.get('media-data').edit_url} /> : null}
PageActions.addNotification('Media removal failed', 'mediaDeleteFail');
}, 100);
if (void 0 !== mediaId) { {userCan.deleteMedia ? (
console.info('Media "' + mediaId + '"' + ' removal failed'); <PopupTrigger contentRef={popupContentRef}>
} <button className="remove-media-icon" title={translateString('Delete media')}>
} <i className="material-icons">delete</i>
</button>
</PopupTrigger>
) : null}
function onClickLoadMore() { {userCan.deleteMedia ? (
setIsContentVisible(!isContentVisible); <PopupContent contentRef={popupContentRef}>
} <PopupMain>
<div className="popup-message">
useEffect(() => { <span className="popup-message-title">Media removal</span>
MediaPageStore.on('media_delete', onMediaDelete); <span className="popup-message-main">You're willing to remove media permanently?</span>
MediaPageStore.on('media_delete_fail', onMediaDeleteFail); </div>
return () => { <hr />
MediaPageStore.removeListener('media_delete', onMediaDelete); <span className="popup-message-bottom">
MediaPageStore.removeListener('media_delete_fail', onMediaDeleteFail); <button className="button-link cancel-comment-removal" onClick={cancelMediaRemoval}>
}; CANCEL
}, []); </button>
<button className="button-link proceed-comment-removal" onClick={proceedMediaRemoval}>
const authorLink = formatInnerLink(props.author.url, SiteContext._currentValue.url); PROCEED
const authorThumb = formatInnerLink(props.author.thumb, SiteContext._currentValue.url); </button>
</span>
function setTimestampAnchors(text) { </PopupMain>
function wrapTimestampWithAnchor(match, string) { </PopupContent>
let split = match.split(':'), ) : null}
s = 0,
m = 1;
while (split.length > 0) {
s += m * parseInt(split.pop(), 10);
m *= 60;
}
const wrapped = `<a href="#" data-timestamp="${s}" class="video-timestamp">${match}</a>`;
return wrapped;
}
const timeRegex = new RegExp('((\\d)?\\d:)?(\\d)?\\d:\\d\\d', 'g');
return text.replace(timeRegex, wrapTimestampWithAnchor);
}
return (
<div className="media-info-content">
{void 0 === PageStore.get('config-media-item').displayAuthor ||
null === PageStore.get('config-media-item').displayAuthor ||
!!PageStore.get('config-media-item').displayAuthor ? (
<MediaAuthorBanner
link={authorLink}
thumb={authorThumb}
name={props.author.name}
published={props.published}
/>
) : null}
<div className="media-content-banner">
<div className="media-content-banner-inner">
{hasSummary ? <div className="media-content-summary">{summary}</div> : null}
{(!hasSummary || isContentVisible) && description ? (
<div
className="media-content-description"
dangerouslySetInnerHTML={{ __html: setTimestampAnchors(description) }}
></div>
) : null}
{hasSummary ? (
<button className="load-more" onClick={onClickLoadMore}>
{isContentVisible ? 'SHOW LESS' : 'SHOW MORE'}
</button>
) : null}
{tagsContent.length ? (
<MediaMetaField
value={tagsContent}
title={1 < tagsContent.length ? translateString('Tags') : translateString('Tag')}
id="tags"
/>
) : null}
{categoriesContent.length ? (
<MediaMetaField
value={categoriesContent}
title={
1 < categoriesContent.length
? translateString('Categories')
: translateString('Category')
}
id="categories"
/>
) : null}
{userCan.editMedia ? (
<div className="media-author-actions">
{userCan.editMedia ? (
<EditMediaButton link={MediaPageStore.get('media-data').edit_url} />
) : null}
{userCan.deleteMedia ? (
<PopupTrigger contentRef={popupContentRef}>
<button className="remove-media-icon" title={translateString('Delete media')}>
<i className="material-icons">delete</i>
</button>
</PopupTrigger>
) : null}
{userCan.deleteMedia ? (
<PopupContent contentRef={popupContentRef}>
<PopupMain>
<div className="popup-message">
<span className="popup-message-title">Media removal</span>
<span className="popup-message-main">
You're willing to remove media permanently?
</span>
</div>
<hr />
<span className="popup-message-bottom">
<button
className="button-link cancel-comment-removal"
onClick={cancelMediaRemoval}
>
CANCEL
</button>
<button
className="button-link proceed-comment-removal"
onClick={proceedMediaRemoval}
>
PROCEED
</button>
</span>
</PopupMain>
</PopupContent>
) : null}
</div>
) : null}
</div>
</div> </div>
) : null}
{!inEmbeddedApp() && <CommentsList />}
</div> </div>
); </div>
<CommentsList />
</div>
);
} }

View File

@ -54,10 +54,6 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
? formatInnerLink(MediaPageStore.get('media-original-url'), SiteContext._currentValue.url) ? formatInnerLink(MediaPageStore.get('media-original-url'), SiteContext._currentValue.url)
: null; : null;
// Extract actual filename from URL for non-video downloads
const originalUrl = MediaPageStore.get('media-original-url');
this.downloadFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : this.props.title;
this.updateStateValues = this.updateStateValues.bind(this); this.updateStateValues = this.updateStateValues.bind(this);
} }
@ -108,9 +104,7 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
render() { render() {
const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views; const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views;
const mediaData = MediaPageStore.get('media-data'); const mediaState = MediaPageStore.get('media-data').state;
const mediaState = mediaData.state;
const isShared = mediaData.is_shared;
let stateTooltip = ''; let stateTooltip = '';
@ -123,8 +117,6 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
break; break;
} }
const sharedTooltip = 'This media is shared with specific users or categories';
return ( return (
<div className="media-title-banner"> <div className="media-title-banner">
{displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle {displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
@ -133,28 +125,15 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
{void 0 !== this.props.title ? <h1>{this.props.title}</h1> : null} {void 0 !== this.props.title ? <h1>{this.props.title}</h1> : null}
{isShared || 'public' !== mediaState ? ( {'public' !== mediaState ? (
<div className="media-labels-area"> <div className="media-labels-area">
<div className="media-labels-area-inner"> <div className="media-labels-area-inner">
{isShared ? ( <span className="media-label-state">
<> <span>{mediaState}</span>
<span className="media-label-state"> </span>
<span>shared</span> <span className="helper-icon" data-tooltip={stateTooltip}>
</span> <i className="material-icons">help_outline</i>
<span className="helper-icon" data-tooltip={sharedTooltip}> </span>
<i className="material-icons">help_outline</i>
</span>
</>
) : 'public' !== mediaState ? (
<>
<span className="media-label-state">
<span>{mediaState}</span>
</span>
<span className="helper-icon" data-tooltip={stateTooltip}>
<i className="material-icons">help_outline</i>
</span>
</>
) : null}
</div> </div>
</div> </div>
) : null} ) : null}
@ -192,7 +171,7 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
.downloadLink ? ( .downloadLink ? (
<VideoMediaDownloadLink /> <VideoMediaDownloadLink />
) : ( ) : (
<OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} /> <OtherMediaDownloadLink link={this.downloadLink} title={this.props.title} />
)} )}
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} /> <MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />

View File

@ -1,119 +1,90 @@
import React from 'react'; import React from 'react';
import { formatViewsNumber, inEmbeddedApp } from '../../utils/helpers/'; import { formatViewsNumber } from '../../utils/helpers/';
import { PageStore, MediaPageStore } from '../../utils/stores/'; import { PageStore, MediaPageStore } from '../../utils/stores/';
import { MemberContext, PlaylistsContext } from '../../utils/contexts/'; import { MemberContext, PlaylistsContext } from '../../utils/contexts/';
import { import { MediaLikeIcon, MediaDislikeIcon, OtherMediaDownloadLink, VideoMediaDownloadLink, MediaSaveButton, MediaShareButton, MediaMoreOptionsIcon } from '../media-actions/';
MediaLikeIcon,
MediaDislikeIcon,
OtherMediaDownloadLink,
VideoMediaDownloadLink,
MediaSaveButton,
MediaShareButton,
MediaMoreOptionsIcon,
} from '../media-actions/';
import ViewerInfoTitleBanner from './ViewerInfoTitleBanner'; import ViewerInfoTitleBanner from './ViewerInfoTitleBanner';
import { translateString } from '../../utils/helpers/'; import { translateString } from '../../utils/helpers/';
export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner { export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
render() { render() {
const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views; const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views;
const mediaData = MediaPageStore.get('media-data'); const mediaState = MediaPageStore.get('media-data').state;
const mediaState = mediaData.state;
const isShared = mediaData.is_shared;
let stateTooltip = ''; let stateTooltip = '';
switch (mediaState) { switch (mediaState) {
case 'private': case 'private':
stateTooltip = 'The site admins have to make its access public'; stateTooltip = 'The site admins have to make its access public';
break; break;
case 'unlisted': case 'unlisted':
stateTooltip = 'The site admins have to make it appear on listings'; stateTooltip = 'The site admins have to make it appear on listings';
break; break;
}
const sharedTooltip = 'This media is shared with specific users or categories';
return (
<div className="media-title-banner">
{displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
? this.mediaCategories(true)
: null}
{void 0 !== this.props.title ? <h1>{this.props.title}</h1> : null}
{isShared || 'public' !== mediaState ? (
<div className="media-labels-area">
<div className="media-labels-area-inner">
{isShared ? (
<>
<span className="media-label-state">
<span>shared</span>
</span>
<span className="helper-icon" data-tooltip={sharedTooltip}>
<i className="material-icons">help_outline</i>
</span>
</>
) : 'public' !== mediaState ? (
<>
<span className="media-label-state">
<span>{mediaState}</span>
</span>
<span className="helper-icon" data-tooltip={stateTooltip}>
<i className="material-icons">help_outline</i>
</span>
</>
) : null}
</div>
</div>
) : null}
<div
className={
'media-views-actions' +
(this.state.likedMedia ? ' liked-media' : '') +
(this.state.dislikedMedia ? ' disliked-media' : '')
}
>
{!displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
? this.mediaCategories()
: null}
{displayViews ? (
<div className="media-views">
{formatViewsNumber(this.props.views, true)}{' '}
{1 >= this.props.views ? translateString('view') : translateString('views')}
</div>
) : null}
<div className="media-actions">
<div>
{MemberContext._currentValue.can.likeMedia ? <MediaLikeIcon /> : null}
{MemberContext._currentValue.can.dislikeMedia ? <MediaDislikeIcon /> : null}
{!inEmbeddedApp() && MemberContext._currentValue.can.shareMedia ? (
<MediaShareButton isVideo={true} />
) : null}
{!inEmbeddedApp() &&
!MemberContext._currentValue.is.anonymous &&
MemberContext._currentValue.can.saveMedia &&
-1 < PlaylistsContext._currentValue.mediaTypes.indexOf(MediaPageStore.get('media-type')) ? (
<MediaSaveButton />
) : null}
{!this.props.allowDownload || !MemberContext._currentValue.can.downloadMedia ? null : !this
.downloadLink ? (
<VideoMediaDownloadLink />
) : (
<OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} />
)}
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
</div>
</div>
</div>
</div>
);
} }
return (
<div className="media-title-banner">
{displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
? this.mediaCategories(true)
: null}
{void 0 !== this.props.title ? <h1>{this.props.title}</h1> : null}
{'public' !== mediaState ? (
<div className="media-labels-area">
<div className="media-labels-area-inner">
<span className="media-label-state">
<span>{mediaState}</span>
</span>
<span className="helper-icon" data-tooltip={stateTooltip}>
<i className="material-icons">help_outline</i>
</span>
</div>
</div>
) : null}
<div
className={
'media-views-actions' +
(this.state.likedMedia ? ' liked-media' : '') +
(this.state.dislikedMedia ? ' disliked-media' : '')
}
>
{!displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
? this.mediaCategories()
: null}
{displayViews ? (
<div className="media-views">
{formatViewsNumber(this.props.views, true)} {1 >= this.props.views ? translateString('view') : translateString('views')}
</div>
) : null}
<div className="media-actions">
<div>
{MemberContext._currentValue.can.likeMedia ? <MediaLikeIcon /> : null}
{MemberContext._currentValue.can.dislikeMedia ? <MediaDislikeIcon /> : null}
{MemberContext._currentValue.can.shareMedia ? <MediaShareButton isVideo={true} /> : null}
{!MemberContext._currentValue.is.anonymous &&
MemberContext._currentValue.can.saveMedia &&
-1 < PlaylistsContext._currentValue.mediaTypes.indexOf(MediaPageStore.get('media-type')) ? (
<MediaSaveButton />
) : null}
{!this.props.allowDownload || !MemberContext._currentValue.can.downloadMedia ? null : !this
.downloadLink ? (
<VideoMediaDownloadLink />
) : (
<OtherMediaDownloadLink link={this.downloadLink} title={this.props.title} />
)}
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
</div>
</div>
</div>
</div>
);
}
} }

View File

@ -1,33 +1,28 @@
.page-main-wrap { .page-main-wrap {
padding-top: var(--header-height); padding-top: var(--header-height);
will-change: padding-left; will-change: padding-left;
@media (min-width: 768px) {
.visible-sidebar & {
padding-left: var(--sidebar-width);
opacity: 1;
}
}
.visible-sidebar #page-media & {
padding-left: 0;
}
@media (min-width: 768px) {
.visible-sidebar & { .visible-sidebar & {
#page-media { padding-left: var(--sidebar-width);
padding-left: 0; opacity: 1;
}
} }
}
body.sliding-sidebar & { .visible-sidebar #page-media & {
transition-property: padding-left; padding-left: 0;
transition-duration: 0.2s; }
}
.embedded-app & { .visible-sidebar & {
padding-top: 0; #page-media {
padding-left: 0; padding-left: 0;
} }
}
body.sliding-sidebar & {
transition-property: padding-left;
transition-duration: 0.2s;
}
} }
#page-profile-media, #page-profile-media,
@ -35,20 +30,20 @@
#page-profile-about, #page-profile-about,
#page-liked.profile-page-liked, #page-liked.profile-page-liked,
#page-history.profile-page-history { #page-history.profile-page-history {
.page-main { .page-main {
min-height: calc(100vh - var(--header-height)); min-height: calc(100vh - var(--header-height));
} }
} }
.page-main { .page-main {
position: relative; position: relative;
width: 100%; width: 100%;
padding-bottom: 16px; padding-bottom: 16px;
} }
.page-main-inner { .page-main-inner {
display: block; display: block;
margin: 1em 1em 0 1em; margin: 1em 1em 0 1em;
} }
#page-profile-media, #page-profile-media,
@ -56,7 +51,7 @@
#page-profile-about, #page-profile-about,
#page-liked.profile-page-liked, #page-liked.profile-page-liked,
#page-history.profile-page-history { #page-history.profile-page-history {
.page-main-wrap { .page-main-wrap {
background-color: var(--body-bg-color); background-color: var(--body-bg-color);
} }
} }

View File

@ -32,7 +32,6 @@ const filters = {
{ id: 'private', title: translateString('Private') }, { id: 'private', title: translateString('Private') },
{ id: 'unlisted', title: translateString('Unlisted') }, { id: 'unlisted', title: translateString('Unlisted') },
{ id: 'public', title: translateString('Published') }, { id: 'public', title: translateString('Published') },
{ id: 'shared', title: translateString('Shared') },
], ],
sort_by: [ sort_by: [
{ id: 'date_added_desc', title: translateString('Upload date (newest)') }, { id: 'date_added_desc', title: translateString('Upload date (newest)') },

View File

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import UrlParse from 'url-parse'; import UrlParse from 'url-parse';
import { ApiUrlContext, MemberContext, SiteContext } from '../utils/contexts/'; import { ApiUrlContext, MemberContext, SiteContext } from '../utils/contexts/';
import { formatInnerLink, csrfToken, postRequest, inEmbeddedApp } from '../utils/helpers/'; import { formatInnerLink, csrfToken, postRequest } from '../utils/helpers/';
import { PageActions } from '../utils/actions/'; import { PageActions } from '../utils/actions/';
import { PageStore, ProfilePageStore } from '../utils/stores/'; import { PageStore, ProfilePageStore } from '../utils/stores/';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader'; import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
@ -268,7 +268,7 @@ export class ProfileAboutPage extends ProfileMediaPage {
return [ return [
this.state.author ? ( this.state.author ? (
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="about" hideChannelBanner={inEmbeddedApp()} /> <ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="about" />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent" enabledContactForm={this.enabledContactForm}> <ProfilePagesContent key="ProfilePagesContent" enabledContactForm={this.enabledContactForm}>

View File

@ -2,7 +2,6 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { ApiUrlConsumer } from '../utils/contexts/'; import { ApiUrlConsumer } from '../utils/contexts/';
import { PageStore } from '../utils/stores/'; import { PageStore } from '../utils/stores/';
import { inEmbeddedApp } from '../utils/helpers/';
import { MediaListWrapper } from '../components/MediaListWrapper'; import { MediaListWrapper } from '../components/MediaListWrapper';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader'; import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent'; import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
@ -29,7 +28,7 @@ export class ProfileHistoryPage extends ProfileMediaPage {
pageContent() { pageContent() {
return [ return [
this.state.author ? ( this.state.author ? (
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="history" hideChannelBanner={inEmbeddedApp()} /> <ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="history" />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent"> <ProfilePagesContent key="ProfilePagesContent">

View File

@ -2,7 +2,6 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { ApiUrlConsumer } from '../utils/contexts/'; import { ApiUrlConsumer } from '../utils/contexts/';
import { PageStore } from '../utils/stores/'; import { PageStore } from '../utils/stores/';
import { inEmbeddedApp } from '../utils/helpers/';
import { MediaListWrapper } from '../components/MediaListWrapper'; import { MediaListWrapper } from '../components/MediaListWrapper';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader'; import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent'; import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
@ -29,7 +28,7 @@ export class ProfileLikedPage extends ProfileMediaPage {
pageContent() { pageContent() {
return [ return [
this.state.author ? ( this.state.author ? (
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="liked" hideChannelBanner={inEmbeddedApp()} /> <ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="liked" />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent"> <ProfilePagesContent key="ProfilePagesContent">

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { ApiUrlConsumer } from '../utils/contexts/'; import { ApiUrlConsumer } from '../utils/contexts/';
import { PageStore } from '../utils/stores/'; import { PageStore } from '../utils/stores/';
import { inEmbeddedApp } from '../utils/helpers/';
import { MediaListWrapper } from '../components/MediaListWrapper'; import { MediaListWrapper } from '../components/MediaListWrapper';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader'; import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent'; import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
@ -31,7 +30,7 @@ export class ProfilePlaylistsPage extends ProfileMediaPage {
pageContent() { pageContent() {
return [ return [
this.state.author ? ( this.state.author ? (
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="playlists" hideChannelBanner={inEmbeddedApp()} /> <ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="playlists" />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent"> <ProfilePagesContent key="ProfilePagesContent">

View File

@ -11,7 +11,7 @@ import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFi
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags'; import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting'; import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
import { BulkActionsModals } from '../components/BulkActionsModals'; import { BulkActionsModals } from '../components/BulkActionsModals';
import { inEmbeddedApp, translateString } from '../utils/helpers'; import { translateString } from '../utils/helpers';
import { withBulkActions } from '../utils/hoc/withBulkActions'; import { withBulkActions } from '../utils/hoc/withBulkActions';
import { Page } from './_Page'; import { Page } from './_Page';
@ -19,443 +19,400 @@ import { Page } from './_Page';
import '../components/profile-page/ProfilePage.scss'; import '../components/profile-page/ProfilePage.scss';
function EmptySharedByMe(props) { function EmptySharedByMe(props) {
return ( return (
<LinksConsumer> <LinksConsumer>
{(links) => ( {(links) => (
<div className="empty-media empty-channel-media"> <div className="empty-media empty-channel-media">
<div className="welcome-title">No shared media</div> <div className="welcome-title">No shared media</div>
<div className="start-uploading">Media that you have shared with others will show up here.</div> <div className="start-uploading">
</div> Media that you have shared with others will show up here.
)} </div>
</LinksConsumer> </div>
); )}
</LinksConsumer>
);
} }
class ProfileSharedByMePage extends Page { class ProfileSharedByMePage extends Page {
constructor(props, pageSlug) { constructor(props, pageSlug) {
super(props, 'string' === typeof pageSlug ? pageSlug : 'author-shared-by-me'); super(props, 'string' === typeof pageSlug ? pageSlug : 'author-shared-by-me');
this.profilePageSlug = 'string' === typeof pageSlug ? pageSlug : 'author-shared-by-me'; this.profilePageSlug = 'string' === typeof pageSlug ? pageSlug : 'author-shared-by-me';
this.state = { this.state = {
channelMediaCount: -1, channelMediaCount: -1,
author: ProfilePageStore.get('author-data'), author: ProfilePageStore.get('author-data'),
uploadsPreviewItemsCount: 0, uploadsPreviewItemsCount: 0,
title: this.props.title, title: this.props.title,
query: ProfilePageStore.get('author-query'), query: ProfilePageStore.get('author-query'),
requestUrl: null, requestUrl: null,
hiddenFilters: true, hiddenFilters: true,
hiddenTags: true, hiddenTags: true,
hiddenSorting: true, hiddenSorting: true,
filterArgs: '', filterArgs: '',
availableTags: [], availableTags: [],
selectedTag: 'all', selectedTag: 'all',
selectedSort: 'date_added_desc', selectedSort: 'date_added_desc',
}; };
this.authorDataLoad = this.authorDataLoad.bind(this); this.authorDataLoad = this.authorDataLoad.bind(this);
this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this); this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this);
this.getCountFunc = this.getCountFunc.bind(this); this.getCountFunc = this.getCountFunc.bind(this);
this.changeRequestQuery = this.changeRequestQuery.bind(this); this.changeRequestQuery = this.changeRequestQuery.bind(this);
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this); this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
this.onToggleTagsClick = this.onToggleTagsClick.bind(this); this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
this.onToggleSortingClick = this.onToggleSortingClick.bind(this); this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
this.onFiltersUpdate = this.onFiltersUpdate.bind(this); this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
this.onTagSelect = this.onTagSelect.bind(this); this.onTagSelect = this.onTagSelect.bind(this);
this.onSortSelect = this.onSortSelect.bind(this); this.onSortSelect = this.onSortSelect.bind(this);
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this); this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
ProfilePageStore.on('load-author-data', this.authorDataLoad); ProfilePageStore.on('load-author-data', this.authorDataLoad);
}
componentDidMount() {
ProfilePageActions.load_author_data();
}
authorDataLoad() {
const author = ProfilePageStore.get('author-data');
let requestUrl = this.state.requestUrl;
if (author) {
if (this.state.query) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_by_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_by_me' + this.state.filterArgs;
}
} }
componentDidMount() { this.setState({
ProfilePageActions.load_author_data(); author: author,
} requestUrl: requestUrl,
});
}
authorDataLoad() { onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) {
const author = ProfilePageStore.get('author-data'); this.setState({
uploadsPreviewItemsCount: totalAuthorPreviewItems,
});
}
let requestUrl = this.state.requestUrl; getCountFunc(count) {
this.setState(
{
channelMediaCount: count,
},
() => {
if (this.state.query) {
let title = '';
if (author) { if (!count) {
if (this.state.query) { title = 'No results for "' + this.state.query + '"';
requestUrl = } else if (1 === count) {
ApiUrlContext._currentValue.media + title = '1 result for "' + this.state.query + '"';
'?author=' + } else {
author.id + title = count + ' results for "' + this.state.query + '"';
'&show=shared_by_me&q=' + }
encodeURIComponent(this.state.query) +
this.state.filterArgs; this.setState({
} else { title: title,
requestUrl = });
ApiUrlContext._currentValue.media +
'?author=' +
author.id +
'&show=shared_by_me' +
this.state.filterArgs;
}
} }
}
);
}
this.setState({ changeRequestQuery(newQuery) {
author: author, if (!this.state.author) {
requestUrl: requestUrl, return;
});
} }
onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) { let requestUrl;
this.setState({
uploadsPreviewItemsCount: totalAuthorPreviewItems, if (newQuery) {
}); requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me&q=' + encodeURIComponent(newQuery) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me' + this.state.filterArgs;
} }
getCountFunc(count) { let title = this.state.title;
this.setState(
{
channelMediaCount: count,
},
() => {
if (this.state.query) {
let title = '';
if (!count) { if ('' === newQuery) {
title = 'No results for "' + this.state.query + '"'; title = this.props.title;
} else if (1 === count) {
title = '1 result for "' + this.state.query + '"';
} else {
title = count + ' results for "' + this.state.query + '"';
}
this.setState({
title: title,
});
}
}
);
} }
changeRequestQuery(newQuery) { this.setState({
requestUrl: requestUrl,
query: newQuery,
title: title,
});
}
onToggleFiltersClick() {
this.setState({
hiddenFilters: !this.state.hiddenFilters,
hiddenTags: true,
hiddenSorting: true,
});
}
onToggleTagsClick() {
this.setState({
hiddenFilters: true,
hiddenTags: !this.state.hiddenTags,
hiddenSorting: true,
});
}
onToggleSortingClick() {
this.setState({
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: !this.state.hiddenSorting,
});
}
onTagSelect(tag) {
this.setState({ selectedTag: tag }, () => {
this.onFiltersUpdate({
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
sort_by: this.state.selectedSort,
tag: tag,
});
});
}
onSortSelect(sortBy) {
this.setState({ selectedSort: sortBy }, () => {
this.onFiltersUpdate({
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
sort_by: sortBy,
tag: this.state.selectedTag,
});
});
}
onFiltersUpdate(updatedArgs) {
const args = {
media_type: null,
upload_date: null,
duration: null,
publish_state: null,
sort_by: null,
ordering: null,
t: null,
};
switch (updatedArgs.media_type) {
case 'video':
case 'audio':
case 'image':
case 'pdf':
args.media_type = updatedArgs.media_type;
break;
}
switch (updatedArgs.upload_date) {
case 'today':
case 'this_week':
case 'this_month':
case 'this_year':
args.upload_date = updatedArgs.upload_date;
break;
}
// Handle duration filter
if (updatedArgs.duration && updatedArgs.duration !== 'all') {
args.duration = updatedArgs.duration;
}
// Handle publish state filter
if (updatedArgs.publish_state && updatedArgs.publish_state !== 'all') {
args.publish_state = updatedArgs.publish_state;
}
switch (updatedArgs.sort_by) {
case 'date_added_desc':
// Default sorting, no need to add parameters
break;
case 'date_added_asc':
args.ordering = 'asc';
break;
case 'alphabetically_asc':
args.sort_by = 'title_asc';
break;
case 'alphabetically_desc':
args.sort_by = 'title_desc';
break;
case 'plays_least':
args.sort_by = 'views_asc';
break;
case 'plays_most':
args.sort_by = 'views_desc';
break;
case 'likes_least':
args.sort_by = 'likes_asc';
break;
case 'likes_most':
args.sort_by = 'likes_desc';
break;
}
if (updatedArgs.tag && updatedArgs.tag !== 'all') {
args.t = updatedArgs.tag;
}
const newArgs = [];
for (let arg in args) {
if (null !== args[arg]) {
newArgs.push(arg + '=' + args[arg]);
}
}
this.setState(
{
filterArgs: newArgs.length ? '&' + newArgs.join('&') : '',
},
function () {
if (!this.state.author) { if (!this.state.author) {
return; return;
} }
let requestUrl; let requestUrl;
if (newQuery) { if (this.state.query) {
requestUrl = requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs;
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_by_me&q=' +
encodeURIComponent(newQuery) +
this.state.filterArgs;
} else { } else {
requestUrl = requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me' + this.state.filterArgs;
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_by_me' +
this.state.filterArgs;
}
let title = this.state.title;
if ('' === newQuery) {
title = this.props.title;
} }
this.setState({ this.setState({
requestUrl: requestUrl, requestUrl: requestUrl,
query: newQuery,
title: title,
}); });
}
);
}
onResponseDataLoaded(responseData) {
if (responseData && responseData.tags) {
const tags = responseData.tags.split(',').map((tag) => tag.trim()).filter((tag) => tag);
this.setState({ availableTags: tags });
} }
}
onToggleFiltersClick() { pageContent() {
this.setState({ const authorData = ProfilePageStore.get('author-data');
hiddenFilters: !this.state.hiddenFilters,
hiddenTags: true,
hiddenSorting: true,
});
}
onToggleTagsClick() { const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
this.setState({
hiddenFilters: true,
hiddenTags: !this.state.hiddenTags,
hiddenSorting: true,
});
}
onToggleSortingClick() { // Check if any filters are active
this.setState({ const hasActiveFilters = this.state.filterArgs && (
hiddenFilters: true, this.state.filterArgs.includes('media_type=') ||
hiddenTags: true, this.state.filterArgs.includes('upload_date=') ||
hiddenSorting: !this.state.hiddenSorting, this.state.filterArgs.includes('duration=') ||
}); this.state.filterArgs.includes('publish_state=')
} );
onTagSelect(tag) { return [
this.setState({ selectedTag: tag }, () => { this.state.author ? (
this.onFiltersUpdate({ <ProfilePagesHeader
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1], key="ProfilePagesHeader"
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1], author={this.state.author}
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1], type="shared_by_me"
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1], onQueryChange={this.changeRequestQuery}
sort_by: this.state.selectedSort, onToggleFiltersClick={this.onToggleFiltersClick}
tag: tag, onToggleTagsClick={this.onToggleTagsClick}
}); onToggleSortingClick={this.onToggleSortingClick}
}); hasActiveFilters={hasActiveFilters}
} hasActiveTags={this.state.selectedTag !== 'all'}
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
onSortSelect(sortBy) { />
this.setState({ selectedSort: sortBy }, () => { ) : null,
this.onFiltersUpdate({ this.state.author ? (
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1], <ProfilePagesContent key="ProfilePagesContent">
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1], <MediaListWrapper
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1], title={this.state.title}
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1], className="items-list-ver"
sort_by: sortBy, showBulkActions={isMediaAuthor}
tag: this.state.selectedTag, selectedCount={this.props.bulkActions.selectedMedia.size}
}); totalCount={this.props.bulkActions.availableMediaIds.length}
}); onBulkAction={this.props.bulkActions.handleBulkAction}
} onSelectAll={this.props.bulkActions.handleSelectAll}
onDeselectAll={this.props.bulkActions.handleDeselectAll}
onFiltersUpdate(updatedArgs) { >
const args = { <ProfileMediaFilters hidden={this.state.hiddenFilters} tags={this.state.availableTags} onFiltersUpdate={this.onFiltersUpdate} />
media_type: null, <ProfileMediaTags hidden={this.state.hiddenTags} tags={this.state.availableTags} onTagSelect={this.onTagSelect} />
upload_date: null, <ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} />
duration: null, <LazyLoadItemListAsync
publish_state: null, key={`${this.state.requestUrl}-${this.props.bulkActions.listKey}`}
sort_by: null, requestUrl={this.state.requestUrl}
ordering: null, hideAuthor={true}
t: null, itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null}
}; hideViews={!PageStore.get('config-media-item').displayViews}
hideDate={!PageStore.get('config-media-item').displayPublishDate}
switch (updatedArgs.media_type) { canEdit={isMediaAuthor}
case 'video': onResponseDataLoaded={this.onResponseDataLoaded}
case 'audio': showSelection={isMediaAuthor}
case 'image': hasAnySelection={this.props.bulkActions.selectedMedia.size > 0}
case 'pdf': selectedMedia={this.props.bulkActions.selectedMedia}
args.media_type = updatedArgs.media_type; onMediaSelection={this.props.bulkActions.handleMediaSelection}
break; onItemsUpdate={this.props.bulkActions.handleItemsUpdate}
} />
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
switch (updatedArgs.upload_date) { <EmptySharedByMe name={this.state.author.name} />
case 'today': ) : null}
case 'this_week': </MediaListWrapper>
case 'this_month': </ProfilePagesContent>
case 'this_year': ) : null,
args.upload_date = updatedArgs.upload_date; this.state.author && isMediaAuthor ? (
break; <BulkActionsModals
} key="BulkActionsModals"
{...this.props.bulkActions}
// Handle duration filter selectedMediaIds={Array.from(this.props.bulkActions.selectedMedia)}
if (updatedArgs.duration && updatedArgs.duration !== 'all') { csrfToken={this.props.bulkActions.getCsrfToken()}
args.duration = updatedArgs.duration; username={this.state.author.username}
} onConfirmCancel={this.props.bulkActions.handleConfirmCancel}
onConfirmProceed={this.props.bulkActions.handleConfirmProceed}
// Handle publish state filter onPermissionModalCancel={this.props.bulkActions.handlePermissionModalCancel}
if (updatedArgs.publish_state && updatedArgs.publish_state !== 'all') { onPermissionModalSuccess={this.props.bulkActions.handlePermissionModalSuccess}
args.publish_state = updatedArgs.publish_state; onPermissionModalError={this.props.bulkActions.handlePermissionModalError}
} onPlaylistModalCancel={this.props.bulkActions.handlePlaylistModalCancel}
onPlaylistModalSuccess={this.props.bulkActions.handlePlaylistModalSuccess}
switch (updatedArgs.sort_by) { onPlaylistModalError={this.props.bulkActions.handlePlaylistModalError}
case 'date_added_desc': onChangeOwnerModalCancel={this.props.bulkActions.handleChangeOwnerModalCancel}
// Default sorting, no need to add parameters onChangeOwnerModalSuccess={this.props.bulkActions.handleChangeOwnerModalSuccess}
break; onChangeOwnerModalError={this.props.bulkActions.handleChangeOwnerModalError}
case 'date_added_asc': onPublishStateModalCancel={this.props.bulkActions.handlePublishStateModalCancel}
args.ordering = 'asc'; onPublishStateModalSuccess={this.props.bulkActions.handlePublishStateModalSuccess}
break; onPublishStateModalError={this.props.bulkActions.handlePublishStateModalError}
case 'alphabetically_asc': onCategoryModalCancel={this.props.bulkActions.handleCategoryModalCancel}
args.sort_by = 'title_asc'; onCategoryModalSuccess={this.props.bulkActions.handleCategoryModalSuccess}
break; onCategoryModalError={this.props.bulkActions.handleCategoryModalError}
case 'alphabetically_desc': onTagModalCancel={this.props.bulkActions.handleTagModalCancel}
args.sort_by = 'title_desc'; onTagModalSuccess={this.props.bulkActions.handleTagModalSuccess}
break; onTagModalError={this.props.bulkActions.handleTagModalError}
case 'plays_least': />
args.sort_by = 'views_asc'; ) : null,
break; ];
case 'plays_most': }
args.sort_by = 'views_desc';
break;
case 'likes_least':
args.sort_by = 'likes_asc';
break;
case 'likes_most':
args.sort_by = 'likes_desc';
break;
}
if (updatedArgs.tag && updatedArgs.tag !== 'all') {
args.t = updatedArgs.tag;
}
const newArgs = [];
for (let arg in args) {
if (null !== args[arg]) {
newArgs.push(arg + '=' + args[arg]);
}
}
this.setState(
{
filterArgs: newArgs.length ? '&' + newArgs.join('&') : '',
},
function () {
if (!this.state.author) {
return;
}
let requestUrl;
if (this.state.query) {
requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_by_me&q=' +
encodeURIComponent(this.state.query) +
this.state.filterArgs;
} else {
requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_by_me' +
this.state.filterArgs;
}
this.setState({
requestUrl: requestUrl,
});
}
);
}
onResponseDataLoaded(responseData) {
if (responseData && responseData.tags) {
const tags = responseData.tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag);
this.setState({ availableTags: tags });
}
}
pageContent() {
const authorData = ProfilePageStore.get('author-data');
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
// Check if any filters are active
const hasActiveFilters =
this.state.filterArgs &&
(this.state.filterArgs.includes('media_type=') ||
this.state.filterArgs.includes('upload_date=') ||
this.state.filterArgs.includes('duration=') ||
this.state.filterArgs.includes('publish_state='));
return [
this.state.author ? (
<ProfilePagesHeader
key="ProfilePagesHeader"
author={this.state.author}
type="shared_by_me"
onQueryChange={this.changeRequestQuery}
onToggleFiltersClick={this.onToggleFiltersClick}
onToggleTagsClick={this.onToggleTagsClick}
onToggleSortingClick={this.onToggleSortingClick}
hasActiveFilters={hasActiveFilters}
hasActiveTags={this.state.selectedTag !== 'all'}
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
hideChannelBanner={inEmbeddedApp()}
/>
) : null,
this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent">
<MediaListWrapper
title={this.state.title}
className="items-list-ver"
showBulkActions={isMediaAuthor}
selectedCount={this.props.bulkActions.selectedMedia.size}
totalCount={this.props.bulkActions.availableMediaIds.length}
onBulkAction={this.props.bulkActions.handleBulkAction}
onSelectAll={this.props.bulkActions.handleSelectAll}
onDeselectAll={this.props.bulkActions.handleDeselectAll}
>
<ProfileMediaFilters
hidden={this.state.hiddenFilters}
tags={this.state.availableTags}
onFiltersUpdate={this.onFiltersUpdate}
/>
<ProfileMediaTags
hidden={this.state.hiddenTags}
tags={this.state.availableTags}
onTagSelect={this.onTagSelect}
/>
<ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} />
<LazyLoadItemListAsync
key={`${this.state.requestUrl}-${this.props.bulkActions.listKey}`}
requestUrl={this.state.requestUrl}
hideAuthor={true}
itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null}
hideViews={!PageStore.get('config-media-item').displayViews}
hideDate={!PageStore.get('config-media-item').displayPublishDate}
canEdit={isMediaAuthor}
onResponseDataLoaded={this.onResponseDataLoaded}
showSelection={isMediaAuthor}
hasAnySelection={this.props.bulkActions.selectedMedia.size > 0}
selectedMedia={this.props.bulkActions.selectedMedia}
onMediaSelection={this.props.bulkActions.handleMediaSelection}
onItemsUpdate={this.props.bulkActions.handleItemsUpdate}
/>
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
<EmptySharedByMe name={this.state.author.name} />
) : null}
</MediaListWrapper>
</ProfilePagesContent>
) : null,
this.state.author && isMediaAuthor ? (
<BulkActionsModals
key="BulkActionsModals"
{...this.props.bulkActions}
selectedMediaIds={Array.from(this.props.bulkActions.selectedMedia)}
csrfToken={this.props.bulkActions.getCsrfToken()}
username={this.state.author.username}
onConfirmCancel={this.props.bulkActions.handleConfirmCancel}
onConfirmProceed={this.props.bulkActions.handleConfirmProceed}
onPermissionModalCancel={this.props.bulkActions.handlePermissionModalCancel}
onPermissionModalSuccess={this.props.bulkActions.handlePermissionModalSuccess}
onPermissionModalError={this.props.bulkActions.handlePermissionModalError}
onPlaylistModalCancel={this.props.bulkActions.handlePlaylistModalCancel}
onPlaylistModalSuccess={this.props.bulkActions.handlePlaylistModalSuccess}
onPlaylistModalError={this.props.bulkActions.handlePlaylistModalError}
onChangeOwnerModalCancel={this.props.bulkActions.handleChangeOwnerModalCancel}
onChangeOwnerModalSuccess={this.props.bulkActions.handleChangeOwnerModalSuccess}
onChangeOwnerModalError={this.props.bulkActions.handleChangeOwnerModalError}
onPublishStateModalCancel={this.props.bulkActions.handlePublishStateModalCancel}
onPublishStateModalSuccess={this.props.bulkActions.handlePublishStateModalSuccess}
onPublishStateModalError={this.props.bulkActions.handlePublishStateModalError}
onCategoryModalCancel={this.props.bulkActions.handleCategoryModalCancel}
onCategoryModalSuccess={this.props.bulkActions.handleCategoryModalSuccess}
onCategoryModalError={this.props.bulkActions.handleCategoryModalError}
onTagModalCancel={this.props.bulkActions.handleTagModalCancel}
onTagModalSuccess={this.props.bulkActions.handleTagModalSuccess}
onTagModalError={this.props.bulkActions.handleTagModalError}
/>
) : null,
];
}
} }
ProfileSharedByMePage.propTypes = { ProfileSharedByMePage.propTypes = {
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
bulkActions: PropTypes.object.isRequired, bulkActions: PropTypes.object.isRequired,
}; };
ProfileSharedByMePage.defaultProps = { ProfileSharedByMePage.defaultProps = {
title: 'Shared by me', title: 'Shared by me',
}; };
// Wrap with HOC and export as named export for compatibility // Wrap with HOC and export as named export for compatibility

View File

@ -10,404 +10,364 @@ import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListA
import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFilters'; import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFilters';
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags'; import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting'; import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
import { inEmbeddedApp, translateString } from '../utils/helpers'; import { translateString } from '../utils/helpers';
import { Page } from './_Page'; import { Page } from './_Page';
import '../components/profile-page/ProfilePage.scss'; import '../components/profile-page/ProfilePage.scss';
function EmptySharedWithMe(props) { function EmptySharedWithMe(props) {
return ( return (
<LinksConsumer> <LinksConsumer>
{(links) => ( {(links) => (
<div className="empty-media empty-channel-media"> <div className="empty-media empty-channel-media">
<div className="welcome-title">No shared media</div> <div className="welcome-title">No shared media</div>
<div className="start-uploading">Media that others have shared with you will show up here.</div> <div className="start-uploading">
</div> Media that others have shared with you will show up here.
)} </div>
</LinksConsumer> </div>
); )}
</LinksConsumer>
);
} }
export class ProfileSharedWithMePage extends Page { export class ProfileSharedWithMePage extends Page {
constructor(props, pageSlug) { constructor(props, pageSlug) {
super(props, 'string' === typeof pageSlug ? pageSlug : 'author-shared-with-me'); super(props, 'string' === typeof pageSlug ? pageSlug : 'author-shared-with-me');
this.profilePageSlug = 'string' === typeof pageSlug ? pageSlug : 'author-shared-with-me'; this.profilePageSlug = 'string' === typeof pageSlug ? pageSlug : 'author-shared-with-me';
this.state = { this.state = {
channelMediaCount: -1, channelMediaCount: -1,
author: ProfilePageStore.get('author-data'), author: ProfilePageStore.get('author-data'),
uploadsPreviewItemsCount: 0, uploadsPreviewItemsCount: 0,
title: this.props.title, title: this.props.title,
query: ProfilePageStore.get('author-query'), query: ProfilePageStore.get('author-query'),
requestUrl: null, requestUrl: null,
hiddenFilters: true, hiddenFilters: true,
hiddenTags: true, hiddenTags: true,
hiddenSorting: true, hiddenSorting: true,
filterArgs: '', filterArgs: '',
availableTags: [], availableTags: [],
selectedTag: 'all', selectedTag: 'all',
selectedSort: 'date_added_desc', selectedSort: 'date_added_desc',
}; };
this.authorDataLoad = this.authorDataLoad.bind(this); this.authorDataLoad = this.authorDataLoad.bind(this);
this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this); this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this);
this.getCountFunc = this.getCountFunc.bind(this); this.getCountFunc = this.getCountFunc.bind(this);
this.changeRequestQuery = this.changeRequestQuery.bind(this); this.changeRequestQuery = this.changeRequestQuery.bind(this);
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this); this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
this.onToggleTagsClick = this.onToggleTagsClick.bind(this); this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
this.onToggleSortingClick = this.onToggleSortingClick.bind(this); this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
this.onFiltersUpdate = this.onFiltersUpdate.bind(this); this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
this.onTagSelect = this.onTagSelect.bind(this); this.onTagSelect = this.onTagSelect.bind(this);
this.onSortSelect = this.onSortSelect.bind(this); this.onSortSelect = this.onSortSelect.bind(this);
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this); this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
ProfilePageStore.on('load-author-data', this.authorDataLoad); ProfilePageStore.on('load-author-data', this.authorDataLoad);
}
componentDidMount() {
ProfilePageActions.load_author_data();
}
authorDataLoad() {
const author = ProfilePageStore.get('author-data');
let requestUrl = this.state.requestUrl;
if (author) {
if (this.state.query) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_with_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_with_me' + this.state.filterArgs;
}
} }
componentDidMount() { this.setState({
ProfilePageActions.load_author_data(); author: author,
} requestUrl: requestUrl,
});
}
authorDataLoad() { onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) {
const author = ProfilePageStore.get('author-data'); this.setState({
uploadsPreviewItemsCount: totalAuthorPreviewItems,
});
}
let requestUrl = this.state.requestUrl; getCountFunc(count) {
this.setState(
{
channelMediaCount: count,
},
() => {
if (this.state.query) {
let title = '';
if (author) { if (!count) {
if (this.state.query) { title = 'No results for "' + this.state.query + '"';
requestUrl = } else if (1 === count) {
ApiUrlContext._currentValue.media + title = '1 result for "' + this.state.query + '"';
'?author=' + } else {
author.id + title = count + ' results for "' + this.state.query + '"';
'&show=shared_with_me&q=' + }
encodeURIComponent(this.state.query) +
this.state.filterArgs; this.setState({
} else { title: title,
requestUrl = });
ApiUrlContext._currentValue.media +
'?author=' +
author.id +
'&show=shared_with_me' +
this.state.filterArgs;
}
} }
}
);
}
this.setState({ changeRequestQuery(newQuery) {
author: author, if (!this.state.author) {
requestUrl: requestUrl, return;
});
} }
onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) { let requestUrl;
this.setState({
uploadsPreviewItemsCount: totalAuthorPreviewItems, if (newQuery) {
}); requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me&q=' + encodeURIComponent(newQuery) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me' + this.state.filterArgs;
} }
getCountFunc(count) { let title = this.state.title;
this.setState(
{
channelMediaCount: count,
},
() => {
if (this.state.query) {
let title = '';
if (!count) { if ('' === newQuery) {
title = 'No results for "' + this.state.query + '"'; title = this.props.title;
} else if (1 === count) {
title = '1 result for "' + this.state.query + '"';
} else {
title = count + ' results for "' + this.state.query + '"';
}
this.setState({
title: title,
});
}
}
);
} }
changeRequestQuery(newQuery) { this.setState({
requestUrl: requestUrl,
query: newQuery,
title: title,
});
}
onToggleFiltersClick() {
this.setState({
hiddenFilters: !this.state.hiddenFilters,
hiddenTags: true,
hiddenSorting: true,
});
}
onToggleTagsClick() {
this.setState({
hiddenFilters: true,
hiddenTags: !this.state.hiddenTags,
hiddenSorting: true,
});
}
onToggleSortingClick() {
this.setState({
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: !this.state.hiddenSorting,
});
}
onTagSelect(tag) {
this.setState({ selectedTag: tag }, () => {
this.onFiltersUpdate({
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
sort_by: this.state.selectedSort,
tag: tag,
});
});
}
onSortSelect(sortBy) {
this.setState({ selectedSort: sortBy }, () => {
this.onFiltersUpdate({
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
sort_by: sortBy,
tag: this.state.selectedTag,
});
});
}
onFiltersUpdate(updatedArgs) {
const args = {
media_type: null,
upload_date: null,
duration: null,
publish_state: null,
sort_by: null,
ordering: null,
t: null,
};
switch (updatedArgs.media_type) {
case 'video':
case 'audio':
case 'image':
case 'pdf':
args.media_type = updatedArgs.media_type;
break;
}
switch (updatedArgs.upload_date) {
case 'today':
case 'this_week':
case 'this_month':
case 'this_year':
args.upload_date = updatedArgs.upload_date;
break;
}
// Handle duration filter
if (updatedArgs.duration && updatedArgs.duration !== 'all') {
args.duration = updatedArgs.duration;
}
// Handle publish state filter
if (updatedArgs.publish_state && updatedArgs.publish_state !== 'all') {
args.publish_state = updatedArgs.publish_state;
}
switch (updatedArgs.sort_by) {
case 'date_added_desc':
// Default sorting, no need to add parameters
break;
case 'date_added_asc':
args.ordering = 'asc';
break;
case 'alphabetically_asc':
args.sort_by = 'title_asc';
break;
case 'alphabetically_desc':
args.sort_by = 'title_desc';
break;
case 'plays_least':
args.sort_by = 'views_asc';
break;
case 'plays_most':
args.sort_by = 'views_desc';
break;
case 'likes_least':
args.sort_by = 'likes_asc';
break;
case 'likes_most':
args.sort_by = 'likes_desc';
break;
}
if (updatedArgs.tag && updatedArgs.tag !== 'all') {
args.t = updatedArgs.tag;
}
const newArgs = [];
for (let arg in args) {
if (null !== args[arg]) {
newArgs.push(arg + '=' + args[arg]);
}
}
this.setState(
{
filterArgs: newArgs.length ? '&' + newArgs.join('&') : '',
},
function () {
if (!this.state.author) { if (!this.state.author) {
return; return;
} }
let requestUrl; let requestUrl;
if (newQuery) { if (this.state.query) {
requestUrl = requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs;
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_with_me&q=' +
encodeURIComponent(newQuery) +
this.state.filterArgs;
} else { } else {
requestUrl = requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me' + this.state.filterArgs;
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_with_me' +
this.state.filterArgs;
}
let title = this.state.title;
if ('' === newQuery) {
title = this.props.title;
} }
this.setState({ this.setState({
requestUrl: requestUrl, requestUrl: requestUrl,
query: newQuery,
title: title,
}); });
}
);
}
onResponseDataLoaded(responseData) {
if (responseData && responseData.tags) {
const tags = responseData.tags.split(',').map((tag) => tag.trim()).filter((tag) => tag);
this.setState({ availableTags: tags });
} }
}
onToggleFiltersClick() { pageContent() {
this.setState({ const authorData = ProfilePageStore.get('author-data');
hiddenFilters: !this.state.hiddenFilters,
hiddenTags: true,
hiddenSorting: true,
});
}
onToggleTagsClick() { const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
this.setState({
hiddenFilters: true,
hiddenTags: !this.state.hiddenTags,
hiddenSorting: true,
});
}
onToggleSortingClick() { // Check if any filters are active
this.setState({ const hasActiveFilters = this.state.filterArgs && (
hiddenFilters: true, this.state.filterArgs.includes('media_type=') ||
hiddenTags: true, this.state.filterArgs.includes('upload_date=') ||
hiddenSorting: !this.state.hiddenSorting, this.state.filterArgs.includes('duration=') ||
}); this.state.filterArgs.includes('publish_state=')
} );
onTagSelect(tag) { return [
this.setState({ selectedTag: tag }, () => { this.state.author ? (
this.onFiltersUpdate({ <ProfilePagesHeader
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1], key="ProfilePagesHeader"
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1], author={this.state.author}
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1], type="shared_with_me"
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1], onQueryChange={this.changeRequestQuery}
sort_by: this.state.selectedSort, onToggleFiltersClick={this.onToggleFiltersClick}
tag: tag, onToggleTagsClick={this.onToggleTagsClick}
}); onToggleSortingClick={this.onToggleSortingClick}
}); hasActiveFilters={hasActiveFilters}
} hasActiveTags={this.state.selectedTag !== 'all'}
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
onSortSelect(sortBy) { />
this.setState({ selectedSort: sortBy }, () => { ) : null,
this.onFiltersUpdate({ this.state.author ? (
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1], <ProfilePagesContent key="ProfilePagesContent">
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1], <MediaListWrapper
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1], title={this.state.title}
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1], className="items-list-ver"
sort_by: sortBy, >
tag: this.state.selectedTag, <ProfileMediaFilters hidden={this.state.hiddenFilters} tags={this.state.availableTags} onFiltersUpdate={this.onFiltersUpdate} />
}); <ProfileMediaTags hidden={this.state.hiddenTags} tags={this.state.availableTags} onTagSelect={this.onTagSelect} />
}); <ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} />
} <LazyLoadItemListAsync
key={this.state.requestUrl}
onFiltersUpdate(updatedArgs) { requestUrl={this.state.requestUrl}
const args = { hideAuthor={true}
media_type: null, itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null}
upload_date: null, hideViews={!PageStore.get('config-media-item').displayViews}
duration: null, hideDate={!PageStore.get('config-media-item').displayPublishDate}
publish_state: null, canEdit={false}
sort_by: null, onResponseDataLoaded={this.onResponseDataLoaded}
ordering: null, />
t: null, {isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
}; <EmptySharedWithMe name={this.state.author.name} />
) : null}
switch (updatedArgs.media_type) { </MediaListWrapper>
case 'video': </ProfilePagesContent>
case 'audio': ) : null,
case 'image': ];
case 'pdf': }
args.media_type = updatedArgs.media_type;
break;
}
switch (updatedArgs.upload_date) {
case 'today':
case 'this_week':
case 'this_month':
case 'this_year':
args.upload_date = updatedArgs.upload_date;
break;
}
// Handle duration filter
if (updatedArgs.duration && updatedArgs.duration !== 'all') {
args.duration = updatedArgs.duration;
}
// Handle publish state filter
if (updatedArgs.publish_state && updatedArgs.publish_state !== 'all') {
args.publish_state = updatedArgs.publish_state;
}
switch (updatedArgs.sort_by) {
case 'date_added_desc':
// Default sorting, no need to add parameters
break;
case 'date_added_asc':
args.ordering = 'asc';
break;
case 'alphabetically_asc':
args.sort_by = 'title_asc';
break;
case 'alphabetically_desc':
args.sort_by = 'title_desc';
break;
case 'plays_least':
args.sort_by = 'views_asc';
break;
case 'plays_most':
args.sort_by = 'views_desc';
break;
case 'likes_least':
args.sort_by = 'likes_asc';
break;
case 'likes_most':
args.sort_by = 'likes_desc';
break;
}
if (updatedArgs.tag && updatedArgs.tag !== 'all') {
args.t = updatedArgs.tag;
}
const newArgs = [];
for (let arg in args) {
if (null !== args[arg]) {
newArgs.push(arg + '=' + args[arg]);
}
}
this.setState(
{
filterArgs: newArgs.length ? '&' + newArgs.join('&') : '',
},
function () {
if (!this.state.author) {
return;
}
let requestUrl;
if (this.state.query) {
requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_with_me&q=' +
encodeURIComponent(this.state.query) +
this.state.filterArgs;
} else {
requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_with_me' +
this.state.filterArgs;
}
this.setState({
requestUrl: requestUrl,
});
}
);
}
onResponseDataLoaded(responseData) {
if (responseData && responseData.tags) {
const tags = responseData.tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag);
this.setState({ availableTags: tags });
}
}
pageContent() {
const authorData = ProfilePageStore.get('author-data');
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
// Check if any filters are active
const hasActiveFilters =
this.state.filterArgs &&
(this.state.filterArgs.includes('media_type=') ||
this.state.filterArgs.includes('upload_date=') ||
this.state.filterArgs.includes('duration=') ||
this.state.filterArgs.includes('publish_state='));
return [
this.state.author ? (
<ProfilePagesHeader
key="ProfilePagesHeader"
author={this.state.author}
type="shared_with_me"
onQueryChange={this.changeRequestQuery}
onToggleFiltersClick={this.onToggleFiltersClick}
onToggleTagsClick={this.onToggleTagsClick}
onToggleSortingClick={this.onToggleSortingClick}
hasActiveFilters={hasActiveFilters}
hasActiveTags={this.state.selectedTag !== 'all'}
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
hideChannelBanner={inEmbeddedApp()}
/>
) : null,
this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent">
<MediaListWrapper title={this.state.title} className="items-list-ver">
<ProfileMediaFilters
hidden={this.state.hiddenFilters}
tags={this.state.availableTags}
onFiltersUpdate={this.onFiltersUpdate}
/>
<ProfileMediaTags
hidden={this.state.hiddenTags}
tags={this.state.availableTags}
onTagSelect={this.onTagSelect}
/>
<ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} />
<LazyLoadItemListAsync
key={this.state.requestUrl}
requestUrl={this.state.requestUrl}
hideAuthor={true}
itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null}
hideViews={!PageStore.get('config-media-item').displayViews}
hideDate={!PageStore.get('config-media-item').displayPublishDate}
canEdit={false}
onResponseDataLoaded={this.onResponseDataLoaded}
/>
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
<EmptySharedWithMe name={this.state.author.name} />
) : null}
</MediaListWrapper>
</ProfilePagesContent>
) : null,
];
}
} }
ProfileSharedWithMePage.propTypes = { ProfileSharedWithMePage.propTypes = {
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
}; };
ProfileSharedWithMePage.defaultProps = { ProfileSharedWithMePage.defaultProps = {
title: 'Shared with me', title: 'Shared with me',
}; };

View File

@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { PageStore, MediaPageStore } from '../utils/stores/'; import { PageStore, MediaPageStore } from '../utils/stores/';
import { MediaPageActions } from '../utils/actions/'; import { MediaPageActions } from '../utils/actions/';
import { inEmbeddedApp } from '../utils/helpers/';
import ViewerError from '../components/media-page/ViewerError'; import ViewerError from '../components/media-page/ViewerError';
import ViewerInfo from '../components/media-page/ViewerInfo'; import ViewerInfo from '../components/media-page/ViewerInfo';
import ViewerSidebar from '../components/media-page/ViewerSidebar'; import ViewerSidebar from '../components/media-page/ViewerSidebar';
@ -11,102 +10,102 @@ import '../components/media-page/MediaPage.scss';
const wideLayoutBreakpoint = 1216; const wideLayoutBreakpoint = 1216;
export class _MediaPage extends Page { export class _MediaPage extends Page {
constructor(props) { constructor(props) {
super(props, 'media'); super(props, 'media');
const isWideLayout = wideLayoutBreakpoint <= window.innerWidth; const isWideLayout = wideLayoutBreakpoint <= window.innerWidth;
this.state = { this.state = {
mediaLoaded: false, mediaLoaded: false,
mediaLoadFailed: false, mediaLoadFailed: false,
wideLayout: isWideLayout, wideLayout: isWideLayout,
infoAndSidebarViewType: !isWideLayout ? 0 : 1, infoAndSidebarViewType: !isWideLayout ? 0 : 1,
viewerClassname: 'cf viewer-section viewer-wide', viewerClassname: 'cf viewer-section viewer-wide',
viewerNestedClassname: 'viewer-section-nested', viewerNestedClassname: 'viewer-section-nested',
pagePlaylistLoaded: false, pagePlaylistLoaded: false,
}; };
this.onWindowResize = this.onWindowResize.bind(this); this.onWindowResize = this.onWindowResize.bind(this);
this.onMediaLoad = this.onMediaLoad.bind(this); this.onMediaLoad = this.onMediaLoad.bind(this);
this.onMediaLoadError = this.onMediaLoadError.bind(this); this.onMediaLoadError = this.onMediaLoadError.bind(this);
this.onPagePlaylistLoad = this.onPagePlaylistLoad.bind(this); this.onPagePlaylistLoad = this.onPagePlaylistLoad.bind(this);
MediaPageStore.on('loaded_media_data', this.onMediaLoad); MediaPageStore.on('loaded_media_data', this.onMediaLoad);
MediaPageStore.on('loaded_media_error', this.onMediaLoadError); MediaPageStore.on('loaded_media_error', this.onMediaLoadError);
MediaPageStore.on('loaded_page_playlist_data', this.onPagePlaylistLoad); MediaPageStore.on('loaded_page_playlist_data', this.onPagePlaylistLoad);
} }
componentDidMount() { componentDidMount() {
MediaPageActions.loadMediaData(); MediaPageActions.loadMediaData();
// FIXME: Is not neccessary to check on every window dimension for changes... // FIXME: Is not neccessary to check on every window dimension for changes...
PageStore.on('window_resize', this.onWindowResize); PageStore.on('window_resize', this.onWindowResize);
} }
onPagePlaylistLoad() { onPagePlaylistLoad() {
this.setState({ this.setState({
pagePlaylistLoaded: true, pagePlaylistLoaded: true,
}); });
} }
onWindowResize() { onWindowResize() {
const isWideLayout = wideLayoutBreakpoint <= window.innerWidth; const isWideLayout = wideLayoutBreakpoint <= window.innerWidth;
this.setState({ this.setState({
wideLayout: isWideLayout, wideLayout: isWideLayout,
infoAndSidebarViewType: !isWideLayout || (MediaPageStore.isVideo() && this.state.theaterMode) ? 0 : 1, infoAndSidebarViewType: !isWideLayout || (MediaPageStore.isVideo() && this.state.theaterMode) ? 0 : 1,
}); });
} }
onMediaLoad() { onMediaLoad() {
this.setState({ mediaLoaded: true }); this.setState({ mediaLoaded: true });
} }
onMediaLoadError() { onMediaLoadError() {
this.setState({ mediaLoadFailed: true }); this.setState({ mediaLoadFailed: true });
} }
viewerContainerContent() { viewerContainerContent() {
return null; return null;
} }
mediaType() { mediaType() {
return null; return null;
} }
pageContent() { pageContent() {
return this.state.mediaLoadFailed ? ( return this.state.mediaLoadFailed ? (
<div className={this.state.viewerClassname}> <div className={this.state.viewerClassname}>
<ViewerError /> <ViewerError />
</div> </div>
) : ( ) : (
<div className={this.state.viewerClassname}> <div className={this.state.viewerClassname}>
<div className="viewer-container" key="viewer-container"> <div className="viewer-container" key="viewer-container">
{this.state.mediaLoaded ? this.viewerContainerContent() : null} {this.state.mediaLoaded ? this.viewerContainerContent() : null}
</div> </div>
<div key="viewer-section-nested" className={this.state.viewerNestedClassname}> <div key="viewer-section-nested" className={this.state.viewerNestedClassname}>
{!this.state.infoAndSidebarViewType {!this.state.infoAndSidebarViewType
? [ ? [
<ViewerInfo key="viewer-info" />, <ViewerInfo key="viewer-info" />,
!inEmbeddedApp() && this.state.pagePlaylistLoaded ? ( this.state.pagePlaylistLoaded ? (
<ViewerSidebar <ViewerSidebar
key="viewer-sidebar" key="viewer-sidebar"
mediaId={MediaPageStore.get('media-id')} mediaId={MediaPageStore.get('media-id')}
playlistData={MediaPageStore.get('playlist-data')} playlistData={MediaPageStore.get('playlist-data')}
/> />
) : null, ) : null,
] ]
: [ : [
!inEmbeddedApp() && this.state.pagePlaylistLoaded ? ( this.state.pagePlaylistLoaded ? (
<ViewerSidebar <ViewerSidebar
key="viewer-sidebar" key="viewer-sidebar"
mediaId={MediaPageStore.get('media-id')} mediaId={MediaPageStore.get('media-id')}
playlistData={MediaPageStore.get('playlist-data')} playlistData={MediaPageStore.get('playlist-data')}
/> />
) : null, ) : null,
<ViewerInfo key="viewer-info" />, <ViewerInfo key="viewer-info" />,
]} ]}
</div> </div>
</div> </div>
); );
} }
} }

View File

@ -2,7 +2,6 @@ import React from 'react';
// FIXME: 'VideoViewerStore' is used only in case of video media, but is included in every media page code. // FIXME: 'VideoViewerStore' is used only in case of video media, but is included in every media page code.
import { PageStore, MediaPageStore, VideoViewerStore } from '../utils/stores/'; import { PageStore, MediaPageStore, VideoViewerStore } from '../utils/stores/';
import { MediaPageActions } from '../utils/actions/'; import { MediaPageActions } from '../utils/actions/';
import { inEmbeddedApp } from '../utils/helpers/';
import ViewerInfoVideo from '../components/media-page/ViewerInfoVideo'; import ViewerInfoVideo from '../components/media-page/ViewerInfoVideo';
import ViewerError from '../components/media-page/ViewerError'; import ViewerError from '../components/media-page/ViewerError';
import ViewerSidebar from '../components/media-page/ViewerSidebar'; import ViewerSidebar from '../components/media-page/ViewerSidebar';
@ -12,119 +11,118 @@ import _MediaPage from './_MediaPage';
const wideLayoutBreakpoint = 1216; const wideLayoutBreakpoint = 1216;
export class _VideoMediaPage extends Page { export class _VideoMediaPage extends Page {
constructor(props) { constructor(props) {
super(props, 'media'); super(props, 'media');
this.state = { this.state = {
wideLayout: wideLayoutBreakpoint <= window.innerWidth, wideLayout: wideLayoutBreakpoint <= window.innerWidth,
mediaLoaded: false, mediaLoaded: false,
mediaLoadFailed: false, mediaLoadFailed: false,
isVideoMedia: false, isVideoMedia: false,
theaterMode: false, // FIXME: Used only in case of video media, but is included in every media page code. theaterMode: false, // FIXME: Used only in case of video media, but is included in every media page code.
pagePlaylistLoaded: false, pagePlaylistLoaded: false,
pagePlaylistData: MediaPageStore.get('playlist-data'), pagePlaylistData: MediaPageStore.get('playlist-data'),
}; };
this.onWindowResize = this.onWindowResize.bind(this); this.onWindowResize = this.onWindowResize.bind(this);
this.onMediaLoad = this.onMediaLoad.bind(this); this.onMediaLoad = this.onMediaLoad.bind(this);
this.onMediaLoadError = this.onMediaLoadError.bind(this); this.onMediaLoadError = this.onMediaLoadError.bind(this);
this.onPagePlaylistLoad = this.onPagePlaylistLoad.bind(this); this.onPagePlaylistLoad = this.onPagePlaylistLoad.bind(this);
MediaPageStore.on('loaded_media_data', this.onMediaLoad); MediaPageStore.on('loaded_media_data', this.onMediaLoad);
MediaPageStore.on('loaded_media_error', this.onMediaLoadError); MediaPageStore.on('loaded_media_error', this.onMediaLoadError);
MediaPageStore.on('loaded_page_playlist_data', this.onPagePlaylistLoad); MediaPageStore.on('loaded_page_playlist_data', this.onPagePlaylistLoad);
}
componentDidMount() {
MediaPageActions.loadMediaData();
// FIXME: Is not neccessary to check on every window dimension for changes...
PageStore.on('window_resize', this.onWindowResize);
}
onWindowResize() {
this.setState({
wideLayout: wideLayoutBreakpoint <= window.innerWidth,
});
}
onPagePlaylistLoad() {
this.setState({
pagePlaylistLoaded: true,
pagePlaylistData: MediaPageStore.get('playlist-data'),
});
}
onMediaLoad() {
const isVideoMedia = 'video' === MediaPageStore.get('media-type') || 'audio' === MediaPageStore.get('media-type');
if (isVideoMedia) {
this.onViewerModeChange = this.onViewerModeChange.bind(this);
VideoViewerStore.on('changed_viewer_mode', this.onViewerModeChange);
this.setState({
mediaLoaded: true,
isVideoMedia: isVideoMedia,
theaterMode: VideoViewerStore.get('in-theater-mode'),
});
} else {
this.setState({
mediaLoaded: true,
isVideoMedia: isVideoMedia,
});
} }
}
componentDidMount() { onViewerModeChange() {
MediaPageActions.loadMediaData(); this.setState({ theaterMode: VideoViewerStore.get('in-theater-mode') });
// FIXME: Is not neccessary to check on every window dimension for changes... }
PageStore.on('window_resize', this.onWindowResize);
}
onWindowResize() { onMediaLoadError(a) {
this.setState({ this.setState({ mediaLoadFailed: true });
wideLayout: wideLayoutBreakpoint <= window.innerWidth, }
});
}
onPagePlaylistLoad() { pageContent() {
this.setState({ const viewerClassname = 'cf viewer-section' + (this.state.theaterMode ? ' theater-mode' : ' viewer-wide');
pagePlaylistLoaded: true, const viewerNestedClassname = 'viewer-section-nested' + (this.state.theaterMode ? ' viewer-section' : '');
pagePlaylistData: MediaPageStore.get('playlist-data'),
});
}
onMediaLoad() { return this.state.mediaLoadFailed ? (
const isVideoMedia = <div className={viewerClassname}>
'video' === MediaPageStore.get('media-type') || 'audio' === MediaPageStore.get('media-type'); <ViewerError />
</div>
if (isVideoMedia) { ) : (
this.onViewerModeChange = this.onViewerModeChange.bind(this); <div className={viewerClassname}>
{[
VideoViewerStore.on('changed_viewer_mode', this.onViewerModeChange); <div className="viewer-container" key="viewer-container">
{this.state.mediaLoaded && this.state.pagePlaylistLoaded
this.setState({ ? this.viewerContainerContent(MediaPageStore.get('media-data'))
mediaLoaded: true, : null}
isVideoMedia: isVideoMedia, </div>,
theaterMode: VideoViewerStore.get('in-theater-mode'), <div key="viewer-section-nested" className={viewerNestedClassname}>
}); {!this.state.wideLayout || (this.state.isVideoMedia && this.state.theaterMode)
} else { ? [
this.setState({ <ViewerInfoVideo key="viewer-info" />,
mediaLoaded: true, this.state.pagePlaylistLoaded ? (
isVideoMedia: isVideoMedia, <ViewerSidebar
}); key="viewer-sidebar"
} mediaId={MediaPageStore.get('media-id')}
} playlistData={MediaPageStore.get('playlist-data')}
/>
onViewerModeChange() { ) : null,
this.setState({ theaterMode: VideoViewerStore.get('in-theater-mode') }); ]
} : [
this.state.pagePlaylistLoaded ? (
onMediaLoadError(a) { <ViewerSidebar
this.setState({ mediaLoadFailed: true }); key="viewer-sidebar"
} mediaId={MediaPageStore.get('media-id')}
playlistData={MediaPageStore.get('playlist-data')}
pageContent() { />
const viewerClassname = 'cf viewer-section' + (this.state.theaterMode ? ' theater-mode' : ' viewer-wide'); ) : null,
const viewerNestedClassname = 'viewer-section-nested' + (this.state.theaterMode ? ' viewer-section' : ''); <ViewerInfoVideo key="viewer-info" />,
return this.state.mediaLoadFailed ? (
<div className={viewerClassname}>
<ViewerError />
</div>
) : (
<div className={viewerClassname}>
{[
<div className="viewer-container" key="viewer-container">
{this.state.mediaLoaded && this.state.pagePlaylistLoaded
? this.viewerContainerContent(MediaPageStore.get('media-data'))
: null}
</div>,
<div key="viewer-section-nested" className={viewerNestedClassname}>
{!this.state.wideLayout || (this.state.isVideoMedia && this.state.theaterMode)
? [
<ViewerInfoVideo key="viewer-info" />,
!inEmbeddedApp() && this.state.pagePlaylistLoaded ? (
<ViewerSidebar
key="viewer-sidebar"
mediaId={MediaPageStore.get('media-id')}
playlistData={MediaPageStore.get('playlist-data')}
/>
) : null,
]
: [
!inEmbeddedApp() && this.state.pagePlaylistLoaded ? (
<ViewerSidebar
key="viewer-sidebar"
mediaId={MediaPageStore.get('media-id')}
playlistData={MediaPageStore.get('playlist-data')}
/>
) : null,
<ViewerInfoVideo key="viewer-info" />,
]}
</div>,
]} ]}
</div> </div>,
); ]}
} </div>
);
}
} }

View File

@ -1,103 +1,101 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; import React, { createContext, useContext, useEffect, useState } from 'react';
import { BrowserCache } from '../classes/'; import { BrowserCache } from '../classes/';
import { PageStore } from '../stores/'; import { PageStore } from '../stores/';
import { addClassname, removeClassname, inEmbeddedApp } from '../helpers/'; import { addClassname, removeClassname } from '../helpers/';
import SiteContext from './SiteContext'; import SiteContext from './SiteContext';
let slidingSidebarTimeout; let slidingSidebarTimeout;
function onSidebarVisibilityChange(visibleSidebar) { function onSidebarVisibilityChange(visibleSidebar) {
clearTimeout(slidingSidebarTimeout); clearTimeout(slidingSidebarTimeout);
addClassname(document.body, 'sliding-sidebar'); addClassname(document.body, 'sliding-sidebar');
slidingSidebarTimeout = setTimeout(function () {
if ('media' === PageStore.get('current-page')) {
if (visibleSidebar) {
addClassname(document.body, 'overflow-hidden');
} else {
removeClassname(document.body, 'overflow-hidden');
}
} else {
if (!visibleSidebar || 767 < window.innerWidth) {
removeClassname(document.body, 'overflow-hidden');
} else {
addClassname(document.body, 'overflow-hidden');
}
}
if (visibleSidebar) {
addClassname(document.body, 'visible-sidebar');
} else {
removeClassname(document.body, 'visible-sidebar');
}
slidingSidebarTimeout = setTimeout(function () { slidingSidebarTimeout = setTimeout(function () {
if ('media' === PageStore.get('current-page')) { slidingSidebarTimeout = null;
if (visibleSidebar) { removeClassname(document.body, 'sliding-sidebar');
addClassname(document.body, 'overflow-hidden'); }, 220);
} else { }, 20);
removeClassname(document.body, 'overflow-hidden');
}
} else {
if (!visibleSidebar || 767 < window.innerWidth) {
removeClassname(document.body, 'overflow-hidden');
} else {
addClassname(document.body, 'overflow-hidden');
}
}
if (visibleSidebar) {
addClassname(document.body, 'visible-sidebar');
} else {
removeClassname(document.body, 'visible-sidebar');
}
slidingSidebarTimeout = setTimeout(function () {
slidingSidebarTimeout = null;
removeClassname(document.body, 'sliding-sidebar');
}, 220);
}, 20);
} }
export const LayoutContext = createContext(); export const LayoutContext = createContext();
export const LayoutProvider = ({ children }) => { export const LayoutProvider = ({ children }) => {
const site = useContext(SiteContext); const site = useContext(SiteContext);
const cache = new BrowserCache('MediaCMS[' + site.id + '][layout]', 86400); const cache = new BrowserCache('MediaCMS[' + site.id + '][layout]', 86400);
const isMediaPage = useMemo(() => PageStore.get('current-page') === 'media', []); const enabledSidebar = !!(document.getElementById('app-sidebar') || document.querySelector('.page-sidebar'));
const isEmbeddedApp = useMemo(() => inEmbeddedApp(), []);
const enabledSidebar = Boolean(document.getElementById('app-sidebar') || document.querySelector('.page-sidebar')); const [visibleSidebar, setVisibleSidebar] = useState(cache.get('visible-sidebar'));
const [visibleMobileSearch, setVisibleMobileSearch] = useState(false);
const [visibleSidebar, setVisibleSidebar] = useState(cache.get('visible-sidebar')); const toggleMobileSearch = () => {
const [visibleMobileSearch, setVisibleMobileSearch] = useState(false); setVisibleMobileSearch(!visibleMobileSearch);
};
const toggleMobileSearch = () => { const toggleSidebar = () => {
setVisibleMobileSearch(!visibleMobileSearch); const newval = !visibleSidebar;
}; onSidebarVisibilityChange(newval);
setVisibleSidebar(newval);
};
const toggleSidebar = () => { useEffect(() => {
const newval = !visibleSidebar; if (visibleSidebar) {
onSidebarVisibilityChange(newval); addClassname(document.body, 'visible-sidebar');
setVisibleSidebar(newval); } else {
}; removeClassname(document.body, 'visible-sidebar');
}
if ('media' !== PageStore.get('current-page') && 1023 < window.innerWidth) {
cache.set('visible-sidebar', visibleSidebar);
}
}, [visibleSidebar]);
useEffect(() => { useEffect(() => {
if (!isEmbeddedApp && visibleSidebar) { PageStore.once('page_init', () => {
addClassname(document.body, 'visible-sidebar'); if ('media' === PageStore.get('current-page')) {
} else { setVisibleSidebar(false);
removeClassname(document.body, 'visible-sidebar'); removeClassname(document.body, 'visible-sidebar');
} }
});
if (!isEmbeddedApp && !isMediaPage && 1023 < window.innerWidth) { setVisibleSidebar(
cache.set('visible-sidebar', visibleSidebar); 'media' !== PageStore.get('current-page') &&
} 1023 < window.innerWidth &&
}, [isEmbeddedApp, isMediaPage, visibleSidebar]); (null === visibleSidebar || visibleSidebar)
);
}, []);
useEffect(() => { const value = {
PageStore.once('page_init', () => { enabledSidebar,
if (isEmbeddedApp || isMediaPage) { visibleSidebar,
setVisibleSidebar(false); setVisibleSidebar,
removeClassname(document.body, 'visible-sidebar'); visibleMobileSearch,
} toggleMobileSearch,
}); toggleSidebar,
};
setVisibleSidebar( return <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>;
!isEmbeddedApp && !isMediaPage && 1023 < window.innerWidth && (null === visibleSidebar || visibleSidebar)
);
}, []);
const value = {
enabledSidebar,
visibleSidebar,
setVisibleSidebar,
visibleMobileSearch,
toggleMobileSearch,
toggleSidebar,
};
return <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>;
}; };
export const LayoutConsumer = LayoutContext.Consumer; export const LayoutConsumer = LayoutContext.Consumer;

View File

@ -1,20 +0,0 @@
export function inEmbeddedApp() {
try {
const params = new URL(globalThis.location.href).searchParams;
const mode = params.get('mode');
if (mode === 'embed_mode') {
sessionStorage.setItem('media_cms_embed_mode', 'true');
return true;
}
if (mode === 'standard') {
sessionStorage.removeItem('media_cms_embed_mode');
return false;
}
return sessionStorage.getItem('media_cms_embed_mode') === 'true';
} catch (e) {
return false;
}
}

View File

@ -14,4 +14,3 @@ export * from './quickSort';
export * from './requests'; export * from './requests';
export { translateString } from './translate'; export { translateString } from './translate';
export { replaceString } from './replacementStrings'; export { replaceString } from './replacementStrings';
export * from './embeddedApp';

View File

@ -1,41 +1,38 @@
import { logErrorAndReturnError } from './errors'; import { logErrorAndReturnError } from './errors';
import { isPositiveInteger, isPositiveIntegerOrZero } from './math';
export const PositiveIntegerOrZero = (function () { export const PositiveIntegerOrZero = (function () {
const isPositiveIntegerOrZero = (x) => x === Math.trunc(x) && x >= 0; return function (obj, key, comp) {
return void 0 === obj[key] || isPositiveIntegerOrZero(obj[key])
return function (obj, key, comp) { ? null
return void 0 === obj[key] || isPositiveIntegerOrZero(obj[key]) : logErrorAndReturnError([
? null 'Invalid prop `' +
: logErrorAndReturnError([ key +
'Invalid prop `' + '` of type `' +
key + typeof obj[key] +
'` of type `' + '` supplied to `' +
typeof obj[key] + (comp || 'N/A') +
'` supplied to `' + '`, expected `positive integer or zero` (' +
(comp || 'N/A') + obj[key] +
'`, expected `positive integer or zero` (' + ').',
obj[key] + ]);
').', };
]);
};
})(); })();
export const PositiveInteger = (function () { export const PositiveInteger = (function () {
const isPositiveInteger = (x) => x === Math.trunc(x) && x > 0; return function (obj, key, comp) {
return void 0 === obj[key] || isPositiveInteger(obj[key])
return function (obj, key, comp) { ? null
return void 0 === obj[key] || isPositiveInteger(obj[key]) : logErrorAndReturnError([
? null 'Invalid prop `' +
: logErrorAndReturnError([ key +
'Invalid prop `' + '` of type `' +
key + typeof obj[key] +
'` of type `' + '` supplied to `' +
typeof obj[key] + (comp || 'N/A') +
'` supplied to `' + '`, expected `positive integer` (' +
(comp || 'N/A') + obj[key] +
'`, expected `positive integer` (' + ').',
obj[key] + ]);
').', };
]);
};
})(); })();

View File

@ -1,15 +1,8 @@
// check templates/config/installation/translations.html for more // check templates/config/installation/translations.html for more
export function replaceString(word) { export function replaceString(string) {
if (!window.REPLACEMENTS) { for (const key in window.REPLACEMENTS) {
return word; string = string.replace(key, window.REPLACEMENTS[key]);
} }
return string;
let result = word;
for (const [search, replacement] of Object.entries(window.REPLACEMENTS)) {
result = result.split(search).join(replacement);
}
return result;
} }

View File

@ -7,13 +7,13 @@ export async function getRequest(url, sync, callback, errorCallback) {
}; };
function responseHandler(result) { function responseHandler(result) {
if (callback instanceof Function || typeof callback === 'function') { if (callback instanceof Function) {
callback(result); callback(result);
} }
} }
function errorHandler(error) { function errorHandler(error) {
if (errorCallback instanceof Function || typeof errorCallback === 'function') { if (errorCallback instanceof Function) {
let err = error; let err = error;
if (void 0 === error.response) { if (void 0 === error.response) {
err = { err = {
@ -58,13 +58,13 @@ export async function postRequest(url, postData, configData, sync, callback, err
postData = postData || {}; postData = postData || {};
function responseHandler(result) { function responseHandler(result) {
if (callback instanceof Function || typeof callback === 'function') { if (callback instanceof Function) {
callback(result); callback(result);
} }
} }
function errorHandler(error) { function errorHandler(error) {
if (errorCallback instanceof Function || typeof errorCallback === 'function') { if (errorCallback instanceof Function) {
errorCallback(error); errorCallback(error);
} }
} }
@ -84,13 +84,13 @@ export async function putRequest(url, putData, configData, sync, callback, error
putData = putData || {}; putData = putData || {};
function responseHandler(result) { function responseHandler(result) {
if (callback instanceof Function || typeof callback === 'function') { if (callback instanceof Function) {
callback(result); callback(result);
} }
} }
function errorHandler(error) { function errorHandler(error) {
if (errorCallback instanceof Function || typeof errorCallback === 'function') { if (errorCallback instanceof Function) {
errorCallback(error); errorCallback(error);
} }
} }
@ -110,13 +110,13 @@ export async function deleteRequest(url, configData, sync, callback, errorCallba
configData = configData || {}; configData = configData || {};
function responseHandler(result) { function responseHandler(result) {
if (callback instanceof Function || typeof callback === 'function') { if (callback instanceof Function) {
callback(result); callback(result);
} }
} }
function errorHandler(error) { function errorHandler(error) {
if (errorCallback instanceof Function || typeof errorCallback === 'function') { if (errorCallback instanceof Function) {
errorCallback(error); errorCallback(error);
} }
} }

View File

@ -1,5 +1,9 @@
// check templates/config/installation/translations.html for more // check templates/config/installation/translations.html for more
export function translateString(str) { export function translateString(string) {
return window.TRANSLATION?.[str] ?? str; if (window.TRANSLATION && window.TRANSLATION[string]) {
return window.TRANSLATION[string];
} else {
return string;
}
} }

View File

@ -1,19 +0,0 @@
import React from 'react';
import { useBulkActions } from '../hooks/useBulkActions';
/**
* Higher-Order Component that provides bulk actions functionality
* to class components via props
*/
export function withBulkActions(WrappedComponent) {
return function WithBulkActionsComponent(props) {
const bulkActions = useBulkActions();
return (
<WrappedComponent
{...props}
bulkActions={bulkActions}
/>
);
};
}

View File

@ -3,83 +3,64 @@ import ReactDOM from 'react-dom';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { LayoutProvider } from './contexts/LayoutContext'; import { LayoutProvider } from './contexts/LayoutContext';
import { UserProvider } from './contexts/UserContext'; import { UserProvider } from './contexts/UserContext';
import { inEmbeddedApp } from './helpers';
const AppProviders = ({ children }) => ( const AppProviders = ({ children }) => (
<LayoutProvider> <LayoutProvider>
<ThemeProvider> <ThemeProvider>
<UserProvider>{children}</UserProvider> <UserProvider>{children}</UserProvider>
</ThemeProvider> </ThemeProvider>
</LayoutProvider> </LayoutProvider>
); );
import { PageHeader, PageSidebar } from '../components/page-layout'; import { PageHeader, PageSidebar } from '../components/page-layout';
export function renderPage(idSelector, PageComponent) { export function renderPage(idSelector, PageComponent) {
if (inEmbeddedApp()) { const appHeader = document.getElementById('app-header');
globalThis.document.body.classList.add('embedded-app'); const appSidebar = document.getElementById('app-sidebar');
globalThis.document.body.classList.remove('visible-sidebar'); const appContent = idSelector ? document.getElementById(idSelector) : undefined;
const appContent = idSelector ? document.getElementById(idSelector) : undefined; if (appContent && PageComponent) {
ReactDOM.render(
if (appContent && PageComponent) { <AppProviders>
ReactDOM.render( {appHeader ? ReactDOM.createPortal(<PageHeader />, appHeader) : null}
<AppProviders> {appSidebar ? ReactDOM.createPortal(<PageSidebar />, appSidebar) : null}
<PageComponent /> <PageComponent />
</AppProviders>, </AppProviders>,
appContent appContent
); );
} } else if (appHeader && appSidebar) {
ReactDOM.render(
return; <AppProviders>
} {ReactDOM.createPortal(<PageHeader />, appHeader)}
<PageSidebar />
const appContent = idSelector ? document.getElementById(idSelector) : undefined; </AppProviders>,
const appHeader = document.getElementById('app-header'); appSidebar
const appSidebar = document.getElementById('app-sidebar'); );
} else if (appHeader) {
if (appContent && PageComponent) { ReactDOM.render(
ReactDOM.render( <LayoutProvider>
<AppProviders> <ThemeProvider>
{appHeader ? ReactDOM.createPortal(<PageHeader />, appHeader) : null} <UserProvider>
{appSidebar ? ReactDOM.createPortal(<PageSidebar />, appSidebar) : null} <PageHeader />
<PageComponent /> </UserProvider>
</AppProviders>, </ThemeProvider>
appContent </LayoutProvider>,
); appSidebar
} else if (appHeader && appSidebar) { );
ReactDOM.render( } else if (appSidebar) {
<AppProviders> ReactDOM.render(
{ReactDOM.createPortal(<PageHeader />, appHeader)} <AppProviders>
<PageSidebar /> <PageSidebar />
</AppProviders>, </AppProviders>,
appSidebar appSidebar
); );
} else if (appHeader) { }
ReactDOM.render(
<LayoutProvider>
<ThemeProvider>
<UserProvider>
<PageHeader />
</UserProvider>
</ThemeProvider>
</LayoutProvider>,
appSidebar
);
} else if (appSidebar) {
ReactDOM.render(
<AppProviders>
<PageSidebar />
</AppProviders>,
appSidebar
);
}
} }
export function renderEmbedPage(idSelector, PageComponent) { export function renderEmbedPage(idSelector, PageComponent) {
const appContent = idSelector ? document.getElementById(idSelector) : undefined; const appContent = idSelector ? document.getElementById(idSelector) : undefined;
if (appContent && PageComponent) { if (appContent && PageComponent) {
ReactDOM.render(<PageComponent />, appContent); ReactDOM.render(<PageComponent />, appContent);
} }
} }

View File

@ -1,56 +0,0 @@
import { csrfToken } from '../../../src/static/js/utils/helpers/csrfToken';
const setupDocumentCookie = () => {
if (typeof document === 'undefined') {
globalThis.document = { cookie: '' } as unknown as Document;
}
};
const setDocumentCookie = (value: string) => {
if (typeof document !== 'undefined') {
Object.defineProperty(document, 'cookie', { value, writable: true, configurable: true });
}
};
describe('js/utils/helpers', () => {
describe('csrfToken', () => {
const originalCookie = document.cookie;
beforeAll(() => {
// Initialize document environment
setupDocumentCookie();
});
afterEach(() => {
// Restore original cookie string
setDocumentCookie(originalCookie);
});
test('Returns null when document.cookie is empty', () => {
setDocumentCookie('');
expect(csrfToken()).toBeNull();
});
test('Returns null when csrftoken is not present', () => {
setDocumentCookie('sessionid=abc; theme=dark');
expect(csrfToken()).toBeNull();
});
test('Finds and decodes the csrftoken cookie value', () => {
const token = encodeURIComponent('a b+c%20');
setDocumentCookie(`sessionid=abc; csrftoken=${token}; theme=dark`);
expect(csrfToken()).toBe('a b+c%20');
});
test('Ignores leading spaces and matches exact prefix csrftoken=', () => {
setDocumentCookie(' sessionid=xyz; csrftoken=secure123; other=value');
expect(csrfToken()).toBe('secure123');
});
test('Stops scanning once csrftoken is found', () => {
// Ensure csrftoken occurs before other long tail cookies
setDocumentCookie('csrftoken=first; a=1; b=2; c=3; d=4; e=5');
expect(csrfToken()).toBe('first');
});
});
});

View File

@ -1,220 +0,0 @@
import {
supportsSvgAsImg,
removeClassname,
addClassname,
hasClassname,
BrowserEvents,
} from '../../../src/static/js/utils/helpers/dom';
declare global {
interface Window {
mozRequestAnimationFrame?: Window['requestAnimationFrame'];
webkitRequestAnimationFrame?: Window['requestAnimationFrame'];
msRequestAnimationFrame?: Window['requestAnimationFrame'];
mozCancelAnimationFrame?: Window['cancelAnimationFrame'];
}
}
describe('js/utils/helpers', () => {
describe('dom', () => {
describe('supportsSvgAsImg', () => {
test('Delegates to document.implementation.hasFeature', () => {
const spy = jest.spyOn(document.implementation as any, 'hasFeature').mockReturnValueOnce(true);
expect(supportsSvgAsImg()).toBe(true);
expect(spy).toHaveBeenCalledWith('http://www.w3.org/TR/SVG11/feature#Image', '1.1');
spy.mockRestore();
});
test('Returns false when feature detection fails', () => {
const spy = jest.spyOn(document.implementation as any, 'hasFeature').mockReturnValueOnce(false);
expect(supportsSvgAsImg()).toBe(false);
spy.mockRestore();
});
});
describe('BrowserEvents', () => {
beforeEach(() => {
jest.spyOn(document, 'addEventListener').mockClear();
jest.spyOn(window, 'addEventListener').mockClear();
document.addEventListener = jest.fn();
window.addEventListener = jest.fn();
});
test('Registers global listeners on construction and invokes callbacks on events', () => {
const be = BrowserEvents();
const visCb = jest.fn();
const resizeCb = jest.fn();
const scrollCb = jest.fn();
// Register callbacks
be.doc(visCb);
be.win(resizeCb, scrollCb);
// Capture the callback passed to addEventListener for each event
const docHandler = (document.addEventListener as jest.Mock).mock.calls.find(
(c) => c[0] === 'visibilitychange'
)?.[1] as Function;
const resizeHandler = (window.addEventListener as jest.Mock).mock.calls.find(
(c) => c[0] === 'resize'
)?.[1] as Function;
const scrollHandler = (window.addEventListener as jest.Mock).mock.calls.find(
(c) => c[0] === 'scroll'
)?.[1] as Function;
// Fire handlers to simulate events
docHandler();
resizeHandler();
scrollHandler();
expect(visCb).toHaveBeenCalledTimes(1);
expect(resizeCb).toHaveBeenCalledTimes(1);
expect(scrollCb).toHaveBeenCalledTimes(1);
});
// @todo: Revisit this behavior
test('Does not register non-function callbacks', () => {
const be = BrowserEvents();
be.win('not-a-fn', null);
be.doc(undefined);
// Should still have registered the listeners on construction
expect(
(document.addEventListener as jest.Mock).mock.calls.some((c) => c[0] === 'visibilitychange')
).toBe(true);
expect((window.addEventListener as jest.Mock).mock.calls.some((c) => c[0] === 'resize')).toBe(true);
expect((window.addEventListener as jest.Mock).mock.calls.some((c) => c[0] === 'scroll')).toBe(true);
});
});
describe('BrowserEvents (edge cases)', () => {
beforeEach(() => {
(document.addEventListener as jest.Mock).mockClear();
(window.addEventListener as jest.Mock).mockClear();
document.addEventListener = jest.fn();
window.addEventListener = jest.fn();
});
test('Multiple callbacks are invoked in order for each event type', () => {
const be = BrowserEvents();
const v1 = jest.fn();
const v2 = jest.fn();
const r1 = jest.fn();
const r2 = jest.fn();
const s1 = jest.fn();
const s2 = jest.fn();
be.doc(v1);
be.doc(v2);
be.win(r1, s1);
be.win(r2, s2);
const docHandler = (document.addEventListener as jest.Mock).mock.calls.find(
(c) => c[0] === 'visibilitychange'
)?.[1] as Function;
const resizeHandler = (window.addEventListener as jest.Mock).mock.calls.find(
(c) => c[0] === 'resize'
)?.[1] as Function;
const scrollHandler = (window.addEventListener as jest.Mock).mock.calls.find(
(c) => c[0] === 'scroll'
)?.[1] as Function;
// Fire events twice to ensure each call triggers callbacks once per firing
docHandler();
resizeHandler();
scrollHandler();
docHandler();
resizeHandler();
scrollHandler();
expect(v1).toHaveBeenCalledTimes(2);
expect(v2).toHaveBeenCalledTimes(2);
expect(r1).toHaveBeenCalledTimes(2);
expect(r2).toHaveBeenCalledTimes(2);
expect(s1).toHaveBeenCalledTimes(2);
expect(s2).toHaveBeenCalledTimes(2);
// Ensure order of invocation within each firing respects registration order
// Jest mock call order grows monotonically; validate the first calls were in the expected sequence
expect(v1.mock.invocationCallOrder[0]).toBeLessThan(v2.mock.invocationCallOrder[0]);
expect(r1.mock.invocationCallOrder[0]).toBeLessThan(r2.mock.invocationCallOrder[0]);
expect(s1.mock.invocationCallOrder[0]).toBeLessThan(s2.mock.invocationCallOrder[0]);
});
// @todo: Check again this behavior
test('Ignores non-function values without throwing and still registers listeners once', () => {
const be = BrowserEvents();
be.doc('noop');
be.win(null, undefined);
const docCount = (document.addEventListener as jest.Mock).mock.calls.filter(
(c) => c[0] === 'visibilitychange'
).length;
const resizeCount = (window.addEventListener as jest.Mock).mock.calls.filter(
(c) => c[0] === 'resize'
).length;
const scrollCount = (window.addEventListener as jest.Mock).mock.calls.filter(
(c) => c[0] === 'scroll'
).length;
expect(docCount).toBe(1);
expect(resizeCount).toBe(1);
expect(scrollCount).toBe(1);
});
});
describe('classname helpers', () => {
test('addClassname uses classList.add when available', () => {
const el = document.createElement('div') as any;
const mockAdd = jest.fn();
el.classList.add = mockAdd;
addClassname(el, 'active');
expect(mockAdd).toHaveBeenCalledWith('active');
});
test('removeClassname uses classList.remove when available', () => {
const el = document.createElement('div') as any;
const mockRemove = jest.fn();
el.classList.remove = mockRemove;
removeClassname(el, 'active');
expect(mockRemove).toHaveBeenCalledWith('active');
});
test('addClassname fallback appends class to className', () => {
const el = document.createElement('div') as any;
el.className = 'one';
// Remove classList to test fallback behavior
delete el.classList;
addClassname(el, 'two');
expect(el.className).toBe('one two');
});
test('removeClassname fallback removes class via regex', () => {
const el = document.createElement('div') as any;
el.className = 'one two three two';
// Remove classList to test fallback behavior
delete el.classList;
removeClassname(el, 'two');
// The regex replacement may leave extra spaces
expect(el.className.replaceAll(/\s+/g, ' ').trim()).toBe('one three');
});
test('hasClassname checks for exact class match boundaries', () => {
const el = document.createElement('div');
el.className = 'one two-three';
expect(hasClassname(el, 'one')).toBe(true);
expect(hasClassname(el, 'two')).toBe(false); // Should not match within two-three
expect(hasClassname(el, 'two-three')).toBe(true);
});
});
});
});

View File

@ -1,47 +0,0 @@
// Mock the './log' module used by errors.ts to capture calls without console side effects
jest.mock('../../../src/static/js/utils/helpers/log', () => ({ error: jest.fn(), warn: jest.fn() }));
import { logErrorAndReturnError, logWarningAndReturnError } from '../../../src/static/js/utils/helpers/errors';
import { error as mockedError, warn as mockedWarn } from '../../../src/static/js/utils/helpers/log';
describe('js/utils/helpers', () => {
describe('errors', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('logErrorAndReturnError returns Error with first message and logs with error', () => {
const messages = ['Primary msg', 'details', 'more'];
const err = logErrorAndReturnError(messages);
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe('Primary msg');
expect(mockedError).toHaveBeenCalledTimes(1);
expect(mockedError).toHaveBeenCalledWith(...messages);
});
test('logWarningAndReturnError returns Error with first message and logs with warn', () => {
const messages = ['Primary msg', 'details', 'more'];
const err = logWarningAndReturnError(messages);
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe('Primary msg');
expect(mockedWarn).toHaveBeenCalledTimes(1);
expect(mockedWarn).toHaveBeenCalledWith(...messages);
});
test('Handles empty array creating an Error with undefined message and logs called with no args', () => {
const messages: string[] = [];
const err1 = logErrorAndReturnError(messages);
expect(err1).toBeInstanceOf(Error);
expect(err1.message).toBe('');
expect(mockedError).toHaveBeenCalledWith('');
jest.clearAllMocks();
const err2 = logWarningAndReturnError(messages);
expect(err2).toBeInstanceOf(Error);
expect(err2.message).toBe('');
expect(mockedWarn).toHaveBeenCalledWith('');
});
});
});

View File

@ -1,44 +0,0 @@
// Mock the dispatcher module used by exportStore
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ register: jest.fn() }));
import exportStore from '../../../src/static/js/utils/helpers/exportStore';
// Re-import the mocked dispatcher for assertions
import * as dispatcher from '../../../src/static/js/utils/dispatcher';
describe('js/utils/helpers', () => {
describe('exportStore', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('Registers store handler with dispatcher and binds context', () => {
const ctx: { value: number; inc?: () => void } = { value: 0 };
const handlerName = 'inc';
const handler = function (this: typeof ctx) {
this.value += 1;
};
ctx[handlerName] = handler as any;
const result = exportStore(ctx, handlerName);
// returns the same store instance
expect(result).toBe(ctx);
// Ensure dispatcher.register was called once with a bound function
expect((dispatcher as any).register).toHaveBeenCalledTimes(1);
const registeredFn = (dispatcher as any).register.mock.calls[0][0] as Function;
expect(typeof registeredFn).toBe('function');
// Verify the registered function is bound to the store context
registeredFn();
expect(ctx.value).toBe(1);
});
test('Throws if handler name does not exist on store', () => {
const store: any = {};
// Accessing store[handler] would be undefined; calling .bind on undefined would throw
expect(() => exportStore(store, 'missing')).toThrow();
});
});
});

View File

@ -1,23 +0,0 @@
import { formatInnerLink } from '../../../src/static/js/utils/helpers/formatInnerLink';
describe('js/utils/helpers', () => {
describe('formatInnerLink', () => {
test('Returns the same absolute URL unchanged', () => {
const url = 'https://example.com/path?x=1#hash';
const base = 'https://base.example.org';
expect(formatInnerLink(url, base)).toBe(url);
});
test('Constructs absolute URL from relative path with leading slash', () => {
const url = '/images/picture.png';
const base = 'https://media.example.com';
expect(formatInnerLink(url, base)).toBe('https://media.example.com/images/picture.png');
});
test('Constructs absolute URL from relative path without leading slash', () => {
const url = 'assets/file.txt';
const base = 'https://cdn.example.com';
expect(formatInnerLink(url, base)).toBe('https://cdn.example.com/assets/file.txt');
});
});
});

View File

@ -1,15 +0,0 @@
import { formatManagementTableDate } from '../../../src/static/js/utils/helpers/formatManagementTableDate';
describe('js/utils/helpers', () => {
describe('formatManagementTableDate', () => {
test('Formats date with zero-padded time components', () => {
const d = new Date(2021, 0, 5, 3, 7, 9); // Jan=0, day 5, 03:07:09
expect(formatManagementTableDate(d)).toBe('Jan 5, 2021 03:07:09');
});
test('Formats date with double-digit time components and month abbreviation', () => {
const d = new Date(1999, 11, 31, 23, 59, 58); // Dec=11
expect(formatManagementTableDate(d)).toBe('Dec 31, 1999 23:59:58');
});
});
});

View File

@ -1,106 +0,0 @@
import formatViewsNumber from '../../../src/static/js/utils/helpers/formatViewsNumber';
describe('js/utils/helpers', () => {
describe('formatViewsNumber', () => {
describe('fullNumber = false (default compact formatting)', () => {
test('Formats values < 1,000 without suffix and with correct rounding', () => {
expect(formatViewsNumber(0)).toBe('0');
expect(formatViewsNumber(9)).toBe('9');
expect(formatViewsNumber(12)).toBe('12');
expect(formatViewsNumber(999)).toBe('999');
});
test('Formats thousands to K with decimals for < 10K and none for >= 10K', () => {
expect(formatViewsNumber(1000)).toBe('1K');
expect(formatViewsNumber(1500)).toBe('1.5K');
expect(formatViewsNumber(1499)).toBe('1.5K'); // rounds to 1 decimal
expect(formatViewsNumber(10_000)).toBe('10K');
expect(formatViewsNumber(10_400)).toBe('10K');
expect(formatViewsNumber(10_500)).toBe('11K'); // rounds to nearest whole
expect(formatViewsNumber(99_900)).toBe('100K'); // rounding up
});
test('Formats millions to M with decimals for < 10M and none for >= 10M', () => {
expect(formatViewsNumber(1_000_000)).toBe('1M');
expect(formatViewsNumber(1_200_000)).toBe('1.2M');
expect(formatViewsNumber(9_440_000)).toBe('9.4M');
expect(formatViewsNumber(9_960_000)).toBe('10M'); // rounds to whole when >= 10M threshold after rounding
expect(formatViewsNumber(10_000_000)).toBe('10M');
});
test('Formats billions and trillions correctly', () => {
expect(formatViewsNumber(1_000_000_000)).toBe('1B');
expect(formatViewsNumber(1_500_000_000)).toBe('1.5B');
expect(formatViewsNumber(10_000_000_000)).toBe('10B');
expect(formatViewsNumber(1_000_000_000_000)).toBe('1T');
expect(formatViewsNumber(1_230_000_000_000)).toBe('1.2T');
});
// @todo: Revisit this behavior
test('Beyond last unit keeps using the last unit with scaling', () => {
// Implementation scales beyond units by increasing the value so that the last unit remains applicable
// Here, expect a number in T with rounding behavior similar to others
expect(formatViewsNumber(9_999_999_999_999)).toBe('10T');
// With current rounding rules, this value rounds to whole trillions
expect(formatViewsNumber(12_345_678_901_234)).toBe('12T');
});
});
describe('fullNumber = true (locale formatting)', () => {
test('Returns locale string representation of the full number', () => {
// Use a fixed locale independent assertion by stripping non-digits except separators that could vary.
// However, to avoid locale variance, check that it equals toLocaleString directly.
const vals = [0, 12, 999, 1000, 1234567, 9876543210];
for (const v of vals) {
expect(formatViewsNumber(v, true)).toBe(v.toLocaleString());
}
});
});
describe('Additional edge cases and robustness', () => {
test('Handles negative values without unit suffix (no scaling applied)', () => {
expect(formatViewsNumber(-999)).toBe('-999');
expect(formatViewsNumber(-1000)).toBe('-1000');
expect(formatViewsNumber(-1500)).toBe('-1500');
expect(formatViewsNumber(-10_500)).toBe('-10500');
expect(formatViewsNumber(-1_230_000_000_000)).toBe('-1230000000000');
});
test('Handles non-integer inputs with correct rounding in compact mode', () => {
expect(formatViewsNumber(1499.5)).toBe('1.5K');
expect(formatViewsNumber(999.9)).toBe('1000');
expect(formatViewsNumber(10_499.5)).toBe('10K');
expect(formatViewsNumber(10_500.49)).toBe('11K');
expect(formatViewsNumber(9_440_000.49)).toBe('9.4M');
});
test('Respects locale formatting in fullNumber mode', () => {
const values = [1_234_567, -2_345_678, 0, 10_000, 99_999_999];
for (const v of values) {
expect(formatViewsNumber(v, true)).toBe(v.toLocaleString());
}
});
test('Caps unit at trillions for extremely large numbers', () => {
expect(formatViewsNumber(9_999_999_999_999)).toBe('10T');
expect(formatViewsNumber(12_345_678_901_234)).toBe('12T');
expect(formatViewsNumber(987_654_321_000_000)).toBe('988T');
});
// @todo: Revisit this behavior
test('Handles NaN and Infinity values gracefully', () => {
expect(formatViewsNumber(Number.NaN, true)).toBe(Number.NaN.toLocaleString());
expect(formatViewsNumber(Number.POSITIVE_INFINITY, true)).toBe(
Number.POSITIVE_INFINITY.toLocaleString()
);
expect(formatViewsNumber(Number.NEGATIVE_INFINITY, true)).toBe(
Number.NEGATIVE_INFINITY.toLocaleString()
);
expect(formatViewsNumber(Number.NaN)).toBe('NaN');
// @note: We don't test compact Infinity cases due to infinite loop behavior from while (views >= compare)
});
});
});
});

View File

@ -1,47 +0,0 @@
import { imageExtension } from '../../../src/static/js/utils/helpers/imageExtension';
describe('js/utils/helpers', () => {
describe('imageExtension', () => {
// @todo: 'imageExtension' behaves as a 'fileExtension' function. It should be renamed...
test('Returns the extension for a typical filename', () => {
expect(imageExtension('photo.png')).toBe('png');
expect(imageExtension('document.pdf')).toBe('pdf');
});
test('Returns the last segment for filenames with multiple dots', () => {
expect(imageExtension('archive.tar.gz')).toBe('gz');
expect(imageExtension('backup.2024.12.31.zip')).toBe('zip');
});
// @todo: It shouldn't happen. Fix that
test('Returns the entire string when there is no dot in the filename', () => {
expect(imageExtension('file')).toBe('file');
expect(imageExtension('README')).toBe('README');
});
test('Handles hidden files that start with a dot', () => {
expect(imageExtension('.gitignore')).toBe('gitignore');
expect(imageExtension('.env.local')).toBe('local');
});
test('Returns undefined for falsy or empty inputs', () => {
expect(imageExtension('')).toBeUndefined();
expect(imageExtension(undefined as unknown as string)).toBeUndefined();
expect(imageExtension(null as unknown as string)).toBeUndefined();
});
test('Extracts the extension from URL-like paths', () => {
expect(imageExtension('https://example.com/images/avatar.jpeg')).toBe('jpeg');
expect(imageExtension('/static/assets/icons/favicon.ico')).toBe('ico');
});
test('Preserves case of the extension', () => {
expect(imageExtension('UPPER.CASE.JPG')).toBe('JPG');
expect(imageExtension('Mixed.Extension.PnG')).toBe('PnG');
});
test('Returns empty string when the filename ends with a trailing dot', () => {
expect(imageExtension('weird.')).toBe('');
});
});
});

View File

@ -1,54 +0,0 @@
import { warn, error } from '../../../src/static/js/utils/helpers/log';
describe('js/utils/helpers', () => {
describe('log', () => {
beforeEach(() => {
// Setup console mocks - replaces global console methods with jest mocks
globalThis.console.warn = jest.fn();
globalThis.console.error = jest.fn();
jest.clearAllMocks();
});
afterEach(() => {
// Restore original console methods
jest.restoreAllMocks();
});
test('Warn proxies arguments to console.warn preserving order and count', () => {
warn('a', 'b', 'c');
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith('a', 'b', 'c');
});
test('Error proxies arguments to console.error preserving order and count', () => {
error('x', 'y');
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith('x', 'y');
});
test('Warn supports zero arguments', () => {
warn();
expect(console.warn).toHaveBeenCalledTimes(1);
expect((console.warn as jest.Mock).mock.calls[0].length).toBe(0);
});
test('Error supports zero arguments', () => {
error();
expect(console.error).toHaveBeenCalledTimes(1);
expect((console.error as jest.Mock).mock.calls[0].length).toBe(0);
});
test('Warn does not call console.error and error does not call console.warn', () => {
warn('only-warn');
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.error).not.toHaveBeenCalled();
jest.clearAllMocks();
error('only-error');
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.warn).not.toHaveBeenCalled();
});
});
});

View File

@ -1,156 +0,0 @@
import {
isGt,
isZero,
isNumber,
isInteger,
isPositive,
isPositiveNumber,
isPositiveInteger,
isPositiveIntegerOrZero,
greaterCommonDivision,
} from '../../../src/static/js/utils/helpers/math';
describe('js/utils/helpers', () => {
describe('math', () => {
describe('isGt', () => {
test('Returns true when x > y', () => {
expect(isGt(5, 3)).toBe(true);
});
test('Returns false when x === y', () => {
expect(isGt(3, 3)).toBe(false);
});
test('Returns false when x < y', () => {
expect(isGt(2, 3)).toBe(false);
});
});
describe('isZero', () => {
test('Returns true for 0', () => {
expect(isZero(0)).toBe(true);
});
test('Returns false for non-zero numbers', () => {
expect(isZero(1)).toBe(false);
expect(isZero(-1)).toBe(false);
});
});
describe('isNumber', () => {
test('Returns true for numbers', () => {
expect(isNumber(0)).toBe(true);
expect(isNumber(1)).toBe(true);
expect(isNumber(-1)).toBe(true);
expect(isNumber(1.5)).toBe(true);
});
test('Returns false for NaN', () => {
expect(isNumber(Number.NaN as unknown as number)).toBe(false);
});
test('Returns false for non-number types (via casting)', () => {
// TypeScript type guards prevent passing non-numbers directly; simulate via casting
expect(isNumber('3' as unknown as number)).toBe(false);
expect(isNumber(null as unknown as number)).toBe(false);
expect(isNumber(undefined as unknown as number)).toBe(false);
});
});
describe('isInteger', () => {
test('Returns true for integers', () => {
expect(isInteger(0)).toBe(true);
expect(isInteger(1)).toBe(true);
expect(isInteger(-1)).toBe(true);
});
test('Returns false for non-integers', () => {
expect(isInteger(1.1)).toBe(false);
expect(isInteger(-2.5)).toBe(false);
});
});
describe('isPositive', () => {
test('Returns true for positive numbers', () => {
expect(isPositive(1)).toBe(true);
expect(isPositive(3.14)).toBe(true);
});
test('Returns false for zero and negatives', () => {
expect(isPositive(0)).toBe(false);
expect(isPositive(-1)).toBe(false);
expect(isPositive(-3.14)).toBe(false);
});
});
describe('isPositiveNumber', () => {
test('Returns true for positive numbers', () => {
expect(isPositiveNumber(1)).toBe(true);
expect(isPositiveNumber(2.7)).toBe(true);
});
test('Returns false for zero and negatives', () => {
expect(isPositiveNumber(0)).toBe(false);
expect(isPositiveNumber(-1)).toBe(false);
expect(isPositiveNumber(-3.4)).toBe(false);
});
test('Returns false for NaN (and non-number when cast)', () => {
expect(isPositiveNumber(Number.NaN as unknown as number)).toBe(false);
expect(isPositiveNumber('3' as unknown as number)).toBe(false);
});
});
describe('isPositiveInteger', () => {
test('Returns true for positive integers', () => {
expect(isPositiveInteger(1)).toBe(true);
expect(isPositiveInteger(10)).toBe(true);
});
test('Returns false for zero, negatives, and non-integers', () => {
expect(isPositiveInteger(0)).toBe(false);
expect(isPositiveInteger(-1)).toBe(false);
expect(isPositiveInteger(1.5)).toBe(false);
});
});
describe('isPositiveIntegerOrZero', () => {
test('Returns true for positive integers and zero', () => {
expect(isPositiveIntegerOrZero(0)).toBe(true);
expect(isPositiveIntegerOrZero(1)).toBe(true);
expect(isPositiveIntegerOrZero(10)).toBe(true);
});
test('Returns false for negatives and non-integers', () => {
expect(isPositiveIntegerOrZero(-1)).toBe(false);
expect(isPositiveIntegerOrZero(1.1)).toBe(false);
});
});
describe('greaterCommonDivision', () => {
test('Computes gcd for positive integers', () => {
expect(greaterCommonDivision(54, 24)).toBe(6);
expect(greaterCommonDivision(24, 54)).toBe(6);
expect(greaterCommonDivision(21, 14)).toBe(7);
expect(greaterCommonDivision(7, 13)).toBe(1);
});
test('Handles zeros', () => {
expect(greaterCommonDivision(0, 0)).toBe(0);
expect(greaterCommonDivision(0, 5)).toBe(5);
expect(greaterCommonDivision(12, 0)).toBe(12);
});
test('Handles negative numbers by returning gcd sign of first arg (Euclid recursion behavior)', () => {
expect(greaterCommonDivision(-54, 24)).toBe(-6);
expect(greaterCommonDivision(54, -24)).toBe(6);
expect(greaterCommonDivision(-54, -24)).toBe(-6);
});
test('Works with equal numbers', () => {
expect(greaterCommonDivision(8, 8)).toBe(8);
expect(greaterCommonDivision(-8, -8)).toBe(-8);
});
});
});
});

View File

@ -1,111 +0,0 @@
// Mock the errors helper to capture error construction without side effects
jest.mock('../../../src/static/js/utils/helpers/errors', () => ({
logErrorAndReturnError: jest.fn((messages: string[]) => new Error(messages.join('\n'))),
}));
import { logErrorAndReturnError } from '../../../src/static/js/utils/helpers/errors';
import { PositiveIntegerOrZero, PositiveInteger } from '../../../src/static/js/utils/helpers/propTypeFilters';
describe('js/utils/helpers', () => {
describe('propTypeFilters', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('PositiveIntegerOrZero', () => {
test('Returns null when property is undefined', () => {
const obj = {};
const res = PositiveIntegerOrZero(obj, 'count', 'Comp');
expect(res).toBeNull();
expect(logErrorAndReturnError).not.toHaveBeenCalled();
});
test('Returns null for zero or positive integers', () => {
const cases = [0, 1, 2, 100];
for (const val of cases) {
const res = PositiveIntegerOrZero({ count: val }, 'count', 'Comp');
expect(res).toBeNull();
}
expect(logErrorAndReturnError).not.toHaveBeenCalled();
});
test('Returns Error via logErrorAndReturnError for negative numbers', () => {
const res = PositiveIntegerOrZero({ count: -1 }, 'count', 'Counter');
expect(res).toBeInstanceOf(Error);
expect(logErrorAndReturnError).toHaveBeenCalledTimes(1);
const [messages] = (logErrorAndReturnError as jest.Mock).mock.calls[0];
expect(Array.isArray(messages)).toBe(true);
expect(messages[0]).toBe(
'Invalid prop `count` of type `number` supplied to `Counter`, expected `positive integer or zero` (-1).'
);
});
test('Returns Error for non-integer numbers (e.g., float)', () => {
const res = PositiveIntegerOrZero({ count: 1.5 }, 'count', 'Widget');
expect(res).toBeInstanceOf(Error);
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
'Invalid prop `count` of type `number` supplied to `Widget`, expected `positive integer or zero` (1.5).'
);
});
test('Uses "N/A" component label when comp is falsy', () => {
const res = PositiveIntegerOrZero({ count: -2 }, 'count', '');
expect(res).toBeInstanceOf(Error);
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
'Invalid prop `count` of type `number` supplied to `N/A`, expected `positive integer or zero` (-2).'
);
});
});
describe('PositiveInteger', () => {
test('Returns null when property is undefined', () => {
const obj = {};
const res = PositiveInteger(obj, 'age', 'Person');
expect(res).toBeNull();
expect(logErrorAndReturnError).not.toHaveBeenCalled();
});
test('Returns null for positive integers (excluding zero)', () => {
const cases = [1, 2, 100];
for (const val of cases) {
const res = PositiveInteger({ age: val }, 'age', 'Person');
expect(res).toBeNull();
}
expect(logErrorAndReturnError).not.toHaveBeenCalled();
});
test('Returns Error for zero', () => {
const res = PositiveInteger({ age: 0 }, 'age', 'Person');
expect(res).toBeInstanceOf(Error);
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toContain(
'Invalid prop `age` of type `number` supplied to `Person`, expected `positive integer` (0).'
);
});
test('Returns Error for negative numbers', () => {
const res = PositiveInteger({ age: -3 }, 'age', 'Person');
expect(res).toBeInstanceOf(Error);
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
'Invalid prop `age` of type `number` supplied to `Person`, expected `positive integer` (-3).'
);
});
test('Returns Error for non-integer numbers', () => {
const res = PositiveInteger({ age: 2.7 }, 'age', 'Person');
expect(res).toBeInstanceOf(Error);
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
'Invalid prop `age` of type `number` supplied to `Person`, expected `positive integer` (2.7).'
);
});
test('Uses "N/A" component label when comp is falsy', () => {
const res = PositiveInteger({ age: -1 }, 'age', '');
expect(res).toBeInstanceOf(Error);
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
'Invalid prop `age` of type `number` supplied to `N/A`, expected `positive integer` (-1).'
);
});
});
});
});

View File

@ -1,33 +0,0 @@
import publishedOnDate from '../../../src/static/js/utils/helpers/publishedOnDate';
// Helper to create Date in UTC to avoid timezone issues in CI environments
const makeDate = (y: number, mZeroBased: number, d: number) => new Date(Date.UTC(y, mZeroBased, d));
describe('js/utils/helpers', () => {
describe('publishedOnDate', () => {
test('Returns null when input is not a Date instance', () => {
expect(publishedOnDate(null as unknown as Date)).toBeNull();
expect(publishedOnDate(undefined as unknown as Date)).toBeNull();
expect(publishedOnDate('2020-01-02' as any as Date)).toBeNull();
expect(publishedOnDate(1577923200000 as unknown as Date)).toBeNull();
});
test('Type 1 (default): "Mon DD, YYYY" with 3-letter month prefix before day', () => {
expect(publishedOnDate(makeDate(2020, 0, 2))).toBe('Jan 2, 2020');
expect(publishedOnDate(makeDate(1999, 11, 31))).toBe('Dec 31, 1999');
expect(publishedOnDate(makeDate(2024, 1, 29))).toBe('Feb 29, 2024');
});
test('Type 2: "DD Mon YYYY" with 3-letter month suffix', () => {
expect(publishedOnDate(makeDate(2020, 0, 2), 2)).toBe('2 Jan 2020');
expect(publishedOnDate(makeDate(1999, 11, 31), 2)).toBe('31 Dec 1999');
expect(publishedOnDate(makeDate(2024, 1, 29), 2)).toBe('29 Feb 2024');
});
test('Type 3: "DD Month YYYY" with full month name', () => {
expect(publishedOnDate(makeDate(2020, 0, 2), 3)).toBe('2 January 2020');
expect(publishedOnDate(makeDate(1999, 11, 31), 3)).toBe('31 December 1999');
expect(publishedOnDate(makeDate(2024, 1, 29), 3)).toBe('29 February 2024');
});
});
});

View File

@ -1,45 +0,0 @@
import { quickSort } from '../../../src/static/js/utils/helpers/quickSort';
describe('js/utils/helpers', () => {
describe('quickSort', () => {
test('Returns the same array reference (in-place) and sorts ascending', () => {
const arr = [3, 1, 4, 1, 5, 9, 2];
const out = quickSort(arr, 0, arr.length - 1);
expect(out).toBe(arr);
expect(arr).toEqual([1, 1, 2, 3, 4, 5, 9]);
});
test('Handles already sorted arrays', () => {
const arr = [1, 2, 3, 4, 5];
quickSort(arr, 0, arr.length - 1);
expect(arr).toEqual([1, 2, 3, 4, 5]);
});
test('Handles arrays with duplicates and negative numbers', () => {
const arr = [0, -1, -1, 2, 2, 1, 0];
quickSort(arr, 0, arr.length - 1);
expect(arr).toEqual([-1, -1, 0, 0, 1, 2, 2]);
});
test('Handles single-element array', () => {
const single = [42];
quickSort(single, 0, single.length - 1);
expect(single).toEqual([42]);
});
test('Handles empty range without changes', () => {
const arr = [5, 4, 3];
// call with left > right (empty range)
quickSort(arr, 2, 1);
expect(arr).toEqual([5, 4, 3]);
});
test('Sorts subrange correctly without touching elements outside range', () => {
const arr = [9, 7, 5, 3, 1, 2, 4, 8, 6];
// sort only the middle [2..6]
quickSort(arr, 2, 6);
// The subrange [5,3,1,2,4] becomes [1,2,3,4,5]
expect(arr).toEqual([9, 7, 1, 2, 3, 4, 5, 8, 6]);
});
});
});

View File

@ -1,68 +0,0 @@
import { replaceString } from '../../../src/static/js/utils/helpers/replacementStrings';
declare global {
interface Window {
REPLACEMENTS?: Record<string, string>;
}
}
describe('js/utils/helpers', () => {
describe('replacementStrings', () => {
describe('replaceString', () => {
const originalReplacements = window.REPLACEMENTS;
beforeEach(() => {
delete window.REPLACEMENTS;
});
afterEach(() => {
window.REPLACEMENTS = originalReplacements;
});
test('Returns the original word when window.REPLACEMENTS is undefined', () => {
delete window.REPLACEMENTS;
const input = 'Hello World';
const output = replaceString(input);
expect(output).toBe(input);
});
test('Replaces a single occurrence based on window.REPLACEMENTS map', () => {
window.REPLACEMENTS = { Hello: 'Hi' };
const output = replaceString('Hello World');
expect(output).toBe('Hi World');
});
test('Replaces multiple occurrences of the same key', () => {
window.REPLACEMENTS = { foo: 'bar' };
const output = replaceString('foo foo baz foo');
expect(output).toBe('bar bar baz bar');
});
test('Applies all entries in window.REPLACEMENTS (sequential split/join)', () => {
window.REPLACEMENTS = { a: 'A', A: 'X' };
// First replaces 'a'->'A' and then 'A'->'X'
const output = replaceString('aAaa');
expect(output).toBe('XXXX');
});
test('Supports empty string replacements (deletion)', () => {
window.REPLACEMENTS = { remove: '' };
const output = replaceString('please remove this');
expect(output).toBe('please this');
});
test('Handles overlapping keys by iteration order', () => {
window.REPLACEMENTS = { ab: 'X', b: 'Y' };
// First replaces 'ab' -> 'X', leaving no 'b' from that sequence, then replace standalone 'b' -> 'Y'
const output = replaceString('zab+b');
expect(output).toBe('zX+Y');
});
test('Works with special regex characters since split/join is literal', () => {
window.REPLACEMENTS = { '.': 'DOT', '*': 'STAR', '[]': 'BRACKETS' };
const output = replaceString('a.*b[]c.');
expect(output).toBe('aDOTSTARbBRACKETScDOT');
});
});
});
});

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