Compare commits

...

14 Commits

Author SHA1 Message Date
Markos Gogoulos
223e87073f
feat: Implement persistent "Embed Mode" to hide UI shell via Session Storage (#1484)
* initial implementation

* updates in ViewerInfoVideoTitleBanner component

* Implement persistent "Embed Mode" to hide UI shell via Session Storage

---------

Co-authored-by: Yiannis <1515939+styiannis@users.noreply.github.com>
2026-01-31 15:27:40 +02:00
Yiannis Stergiou
1c15880ae3
feat: frontend unit tests 2026-01-07 19:47:54 +02:00
Markos Gogoulos
ed5cfa1a84
add icon on media profile page 2025-12-24 17:18:30 +02:00
Markos Gogoulos
2fe48d8522 fix formatting 2025-12-24 12:29:25 +02:00
Josh Preston
90331f3b4a
Fix: Add regex denoter and improve celerybeat gitignore (#1446)
* (bugfix): Added celerybeat extras to gitignore

* (bugfix): fixed missing regex denoter

* Fix .dockerignore node_modules pattern and add comprehensive exclusions

- Fix #1398: Change 'node_modules' to '**/node_modules' to exclude all nested directories
- Add patterns for Python bytecode, IDE files, logs, and build artifacts
- Consolidate node_modules patterns in .gitignore to use **/node_modules/
2025-12-24 12:28:55 +02:00
Josh Preston
c57f528ab1
Add missing migration for Meta options on Subtitle, TranscriptionRequest, and VideoTrimRequest (#1448)
Fixes #1447

This migration adds the missing AlterModelOptions operations for:
- Subtitle model (verbose_name: 'Caption', verbose_name_plural: 'Captions')
- TranscriptionRequest model (verbose_name: 'Caption Request', verbose_name_plural: 'Caption Requests')
- VideoTrimRequest model (verbose_name: 'Trim Request', verbose_name_plural: 'Trim Requests')

These Meta options were defined in the models but never migrated, causing
makemigrations --dry-run to show pending migrations on fresh clones.
2025-12-24 12:18:48 +02:00
Markos Gogoulos
fa67ffffb4
replace media, shared state, better category options 2025-12-24 12:14:01 +02:00
Markos Gogoulos
872571350f static files 2025-12-22 11:14:35 +02:00
Markos Gogoulos
665971856b version bump 2025-12-22 11:12:37 +02:00
Yiannis Christodoulou
d9b1d6cab1
feat: Improve Visual Distinction Between Trim and Chapters Editors (#1445)
* Update .gitignore

* feat: Improve Visual Distinction Between Trim and Chapters Editors

* fix: Convert timeline header styles to CSS classes

Moved inline styles for timeline headers in chapters and video editors to dedicated CSS classes for better maintainability and consistency.

* Bump version to 7.3.0

Update the VERSION in cms/version.py to 7.3.0 for the new release.

* build assets

* Update segment color schemes in video and chapters editor.

* build assets

* build assets

* fix: Prevent Safari from resetting segments after drag operations

Prevent Safari from resetting segments when loadedmetadata fires multiple times and fix stale state issues in click handlers by using refs instead of closure variables.

* build assets

* Bump version to 7.3.0-beta.3

Update the VERSION string in cms/version.py to reflect the new pre-release version 7.3.0-beta.3.
2025-12-22 11:12:19 +02:00
Markos Gogoulos
aeef8284bf docs: update page link 2025-12-01 11:29:58 +02:00
Markos Gogoulos
a90fcbf8dd version bump 2025-11-21 12:30:12 +02:00
Markos Gogoulos
1b3cdfd302 fix: add delay to task creation 2025-11-21 12:30:05 +02:00
Yiannis Christodoulou
cd7dd4f72c
fix: Chapter numbering and preserve custom titles on segment reorder (#1435)
* FIX: Preserve custom chapter titles when renumbering (151)

Updated the renumberAllSegments function to only update chapter titles that match the default 'Chapter X' pattern, preserving any custom titles. Also ensured segments are renumbered after updates for consistent chronological naming.

* build assets (chapters editor)
2025-11-21 12:29:19 +02:00
150 changed files with 11905 additions and 5827 deletions

View File

@ -1,2 +1,69 @@
node_modules # Node.js/JavaScript dependencies and artifacts
npm-debug.log **/node_modules
**/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

@ -0,0 +1,42 @@
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,8 +6,9 @@ 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/
@ -19,8 +20,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
/frontend-tools/video-editor/node_modules # Node.js dependencies (covers all node_modules directories, including frontend-tools)
/frontend-tools/video-editor/client/node_modules **/node_modules/
/static_collected /static_collected
/frontend-tools/video-editor-v1 /frontend-tools/video-editor-v1
frontend-tools/.DS_Store frontend-tools/.DS_Store
@ -35,3 +36,4 @@ 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. Contact us at info@mediacms.io for more information. 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.
### Commercial Hostings ### Commercial Hostings
**Elestio** **Elestio**

View File

@ -563,7 +563,8 @@ ALLOW_VIDEO_TRIMMER = True
ALLOW_CUSTOM_MEDIA_URLS = False ALLOW_CUSTOM_MEDIA_URLS = False
# Whether to allow anonymous users to list all users ALLOW_MEDIA_REPLACEMENT = False
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.2.1" VERSION = "7.7"

View File

@ -58,6 +58,7 @@ 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,6 +6,7 @@ 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):
@ -121,13 +122,18 @@ 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": MultipleSelect(), "category": CategoryModalWidget(),
} }
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
@ -140,6 +146,13 @@ 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
@ -178,7 +191,35 @@ 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 state in ['private', 'unlisted']: if self.is_shared and state != "shared":
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() 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 or custom_permissions:
@ -189,7 +230,7 @@ class MediaPublishForm(forms.ModelForm):
state_index = i state_index = i
break break
if state_index: if state_index is not None:
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)
@ -202,11 +243,24 @@ class MediaPublishForm(forms.ModelForm):
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" 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) 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)
@ -333,3 +387,35 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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,6 +165,7 @@ 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,6 +162,7 @@ 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,6 +163,7 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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,6 +163,7 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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,6 +162,7 @@ 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

@ -0,0 +1,24 @@
# 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,7 +270,9 @@ 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
self.media_init() from .. import tasks
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
@ -329,10 +331,17 @@ 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.delay(self.friendly_token, translate_to_english=False) tasks.whisper_transcribe.apply_async(
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.delay(self.friendly_token, translate_to_english=True) tasks.whisper_transcribe.apply_async(
args=[self.friendly_token, True],
countdown=10,
)
def update_search_vector(self): def update_search_vector(self):
""" """
@ -410,6 +419,11 @@ 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:

View File

@ -101,10 +101,17 @@ 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 = (
@ -133,6 +140,7 @@ 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,6 +625,18 @@ 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,6 +20,7 @@ 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"),
@ -110,7 +111,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("^(?P<slug>[\w.-]*)$", views.get_page, name="get_page"), # noqa: W605 re_path(r"^(?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,6 +32,7 @@ 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,8 +226,13 @@ 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 and publish_state in ['private', 'public', 'unlisted']: if publish_state:
media = media.filter(state=publish_state) if publish_state == 'shared':
# 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}")
@ -799,13 +804,14 @@ 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():
serializer.save(user=request.user)
# no need to update the media file itself, only the metadata
# if request.data.get('media_file'): # if request.data.get('media_file'):
# media_file = request.data["media_file"] # media_file = request.data["media_file"]
# serializer.save(user=request.user, media_file=media_file) # media.state = helpers.get_default_state(request.user)
# media.listable = False
# serializer.save(user=request.user, media_file=media_file)
# else: # else:
# serializer.save(user=request.user) # serializer.save(user=request.user)
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,4 +1,5 @@
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
@ -18,6 +19,7 @@ from ..forms import (
EditSubtitleForm, EditSubtitleForm,
MediaMetadataForm, MediaMetadataForm,
MediaPublishForm, MediaPublishForm,
ReplaceMediaForm,
SubtitleForm, SubtitleForm,
WhisperSubtitlesForm, WhisperSubtitlesForm,
) )
@ -363,6 +365,76 @@ 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"""

39
files/widgets.py Normal file
View File

@ -0,0 +1,39 @@
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,6 +150,11 @@ 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 8 // Return CSS class based on index modulo 20
// This matches the CSS nth-child selectors in the timeline // This matches the CSS classes for up to 20 segments
return `segment-default-color segment-color-${(index % 8) + 1}`; return `segment-default-color segment-color-${(index % 20) + 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 Segment" aria-label="Delete Chapter"
data-tooltip="Delete this segment" data-tooltip="Delete this chapter"
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

@ -177,7 +177,16 @@ 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(() => {
@ -867,6 +876,12 @@ 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]);
@ -1084,16 +1099,20 @@ 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): number => { const calculateAvailableSpace = (startTime: number, segmentsOverride?: Segment[]): 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 = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...segmentsToUse].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);
@ -1109,14 +1128,6 @@ 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);
}; };
@ -1125,8 +1136,11 @@ 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 = clipSegments.find((seg) => { const segmentAtPosition = currentSegments.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;
@ -1134,7 +1148,7 @@ const TimelineControls = ({
}); });
// Find the next and previous segments // Find the next and previous segments
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...currentSegments].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);
@ -1144,21 +1158,13 @@ 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 // Calculate available space for new segment using current segments
const availableSpace = calculateAvailableSpace(currentPosition); const availableSpace = calculateAvailableSpace(currentPosition, currentSegments);
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
@ -1188,6 +1194,12 @@ 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;
@ -1195,7 +1207,6 @@ 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);
@ -1216,14 +1227,6 @@ 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;
@ -1236,8 +1239,12 @@ 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 = clipSegments.find((seg) => { const segmentAtClickedTime = currentSegments.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)
@ -1258,7 +1265,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 = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const orderedSegments = [...currentSegments].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) {
@ -1311,8 +1318,9 @@ 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 // Calculate the available space for a new segment using current segments from ref
const availableSpace = calculateAvailableSpace(newTime); // This ensures we use the latest segments even if React hasn't re-rendered yet
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
@ -1334,18 +1342,6 @@ 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',
});
} }
} }
}; };
@ -1498,6 +1494,10 @@ 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,6 +1582,26 @@ 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(
@ -1594,6 +1614,13 @@ 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;
@ -3943,9 +3970,7 @@ const TimelineControls = ({
<button <button
onClick={() => setShowSaveChaptersModal(true)} onClick={() => setShowSaveChaptersModal(true)}
className="save-chapters-button" className="save-chapters-button"
data-tooltip={clipSegments.length === 0 {...(clipSegments.length === 0 && { 'data-tooltip': 'Clear all chapters' })}
? "Clear all chapters"
: "Save chapters"}
> >
{clipSegments.length === 0 {clipSegments.length === 0
? 'Clear Chapters' ? 'Clear Chapters'

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}`;
}; };
@ -30,10 +30,16 @@ const useVideoChapters = () => {
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
return sortedSegments.map((segment, index) => ({ // Only update titles that follow the default "Chapter X" pattern to preserve custom titles
...segment, return sortedSegments.map((segment, index) => {
chapterTitle: `Chapter ${index + 1}` const currentTitle = segment.chapterTitle || '';
})); 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
@ -55,6 +61,9 @@ const useVideoChapters = () => {
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);
const [trimEnd, setTrimEnd] = useState(0); const [trimEnd, setTrimEnd] = useState(0);
@ -102,11 +111,7 @@ 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;
const isSafariBrowser = /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent); return /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
@ -115,7 +120,15 @@ const useVideoChapters = () => {
if (!video) return; if (!video) return;
const handleLoadedMetadata = () => { const handleLoadedMetadata = () => {
logger.debug('Video loadedmetadata event fired, duration:', video.duration); // CRITICAL: Prevent re-initialization if editor has already been initialized
// 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);
@ -124,9 +137,7 @@ 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 = const existingChapters = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || [];
(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
@ -169,7 +180,7 @@ const useVideoChapters = () => {
setHistory([initialState]); setHistory([initialState]);
setHistoryPosition(0); setHistoryPosition(0);
setClipSegments(initialSegments); setClipSegments(initialSegments);
logger.debug('Editor initialized with segments:', initialSegments.length); isInitializedRef.current = true; // Mark as initialized
}; };
initializeEditor(); initializeEditor();
@ -177,20 +188,18 @@ 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
if (video.duration && duration === 0) { // Also check if already initialized to prevent re-initialization
logger.debug('Safari fallback: Using canplay event to initialize'); if (video.duration && duration === 0 && !isInitializedRef.current) {
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
if (video.duration && duration === 0) { // Also check if already initialized to prevent re-initialization
logger.debug('Safari fallback: Using loadeddata event to initialize'); if (video.duration && duration === 0 && !isInitializedRef.current) {
handleLoadedMetadata(); handleLoadedMetadata();
} }
}; };
@ -222,14 +231,12 @@ 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) { if (video.duration && duration === 0 && !isInitializedRef.current) {
logger.debug('Safari timeout fallback: Force initializing editor');
handleLoadedMetadata(); handleLoadedMetadata();
} }
}, 1000); }, 1000);
@ -564,8 +571,11 @@ 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(e.detail.segments); setClipSegments(renumberedSegments);
// Always save state to history for non-intermediate actions // Always save state to history for non-intermediate actions
if (isSignificantChange) { if (isSignificantChange) {
@ -573,7 +583,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(e.detail.segments)); const segmentsClone = JSON.parse(JSON.stringify(renumberedSegments));
// Create a complete state snapshot // Create a complete state snapshot
const stateWithAction: EditorState = { const stateWithAction: EditorState = {

View File

@ -82,27 +82,24 @@
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--foreground, #333); color: var(--foreground, #333);
margin: 0; margin-bottom: 0.75rem;
} }
.save-chapters-button { .save-chapters-button {
display: flex; color: #ffffff;
align-items: center; background: #059669;
gap: 0.5rem; border-radius: 0.25rem;
padding: 0.5rem 1rem; font-size: 0.75rem;
background-color: #3b82f6; padding: 0.25rem 0.5rem;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; border: none;
white-space: nowrap;
transition: background-color 0.2s;
min-width: fit-content;
&:hover { &:hover {
background-color: #2563eb; background-color: #059669;
transform: translateY(-1px); box-shadow: 0 4px 6px -1px rgba(5, 150, 105, 0.3);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
} }
&.has-changes { &.has-changes {
@ -205,9 +202,9 @@
} }
&.selected { &.selected {
border-color: #3b82f6; border-color: #059669;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1);
background-color: rgba(59, 130, 246, 0.05); background-color: rgba(5, 150, 105, 0.05);
} }
} }
@ -287,29 +284,68 @@
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(59, 130, 246, 0.15); background-color: rgba(167, 243, 208, 0.2);
} }
.segment-color-2 { .segment-color-2 {
background-color: rgba(16, 185, 129, 0.15); background-color: rgba(134, 239, 172, 0.2);
} }
.segment-color-3 { .segment-color-3 {
background-color: rgba(245, 158, 11, 0.15); background-color: rgba(101, 235, 136, 0.2);
} }
.segment-color-4 { .segment-color-4 {
background-color: rgba(239, 68, 68, 0.15); background-color: rgba(68, 231, 100, 0.2);
} }
.segment-color-5 { .segment-color-5 {
background-color: rgba(139, 92, 246, 0.15); background-color: rgba(35, 227, 64, 0.2);
} }
.segment-color-6 { .segment-color-6 {
background-color: rgba(236, 72, 153, 0.15); background-color: rgba(20, 207, 54, 0.2);
} }
.segment-color-7 { .segment-color-7 {
background-color: rgba(6, 182, 212, 0.15); background-color: rgba(15, 187, 48, 0.2);
} }
.segment-color-8 { .segment-color-8 {
background-color: rgba(250, 204, 21, 0.15); background-color: rgba(10, 167, 42, 0.2);
}
.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: #0066cc; color: #059669;
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: #0066cc; background-color: #059669;
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: #0066cc; background-color: #059669;
color: white; color: white;
} }
.modal-button-primary:hover { .modal-button-primary:hover {
background-color: #0055aa; background-color: #059669;
} }
.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 #0066cc; border-top: 4px solid #059669;
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: #0066cc; background-color: #059669;
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: #0066cc; background-color: #059669;
color: white; color: white;
} }
.centered-choice:hover { .centered-choice:hover {
background-color: #0055aa; background-color: #059669;
} }
@media (max-width: 480px) { @media (max-width: 480px) {
@ -300,7 +300,7 @@
.countdown { .countdown {
font-weight: bold; font-weight: bold;
color: #0066cc; color: #059669;
font-size: 1.1rem; font-size: 1.1rem;
} }
} }

View File

@ -1,4 +1,16 @@
#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;
@ -11,6 +23,8 @@
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 {
@ -20,7 +34,7 @@
} }
.timeline-title-text { .timeline-title-text {
font-weight: 700; font-size: 0.875rem;
} }
.current-time { .current-time {
@ -48,10 +62,11 @@
.timeline-container { .timeline-container {
position: relative; position: relative;
min-width: 100%; min-width: 100%;
background-color: #fafbfc; background-color: #e2ede4;
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 {
@ -194,7 +209,7 @@
left: 0; left: 0;
right: 0; right: 0;
padding: 0.4rem; padding: 0.4rem;
background-color: rgba(0, 0, 0, 0.4); background-color: rgba(16, 185, 129, 0.6);
color: white; color: white;
opacity: 1; opacity: 1;
transition: background-color 0.2s; transition: background-color 0.2s;
@ -202,15 +217,15 @@
} }
.clip-segment:hover .clip-segment-info { .clip-segment:hover .clip-segment-info {
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(16, 185, 129, 0.7);
} }
.clip-segment.selected .clip-segment-info { .clip-segment.selected .clip-segment-info {
background-color: rgba(59, 130, 246, 0.5); background-color: rgba(5, 150, 105, 0.8);
} }
.clip-segment.selected:hover .clip-segment-info { .clip-segment.selected:hover .clip-segment-info {
background-color: rgba(59, 130, 246, 0.4); background-color: rgba(5, 150, 105, 0.75);
} }
.clip-segment-name { .clip-segment-name {
@ -540,7 +555,7 @@
.save-copy-button, .save-copy-button,
.save-segments-button { .save-segments-button {
color: #ffffff; color: #ffffff;
background: #0066cc; background: #059669;
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;
@ -713,7 +728,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: #0066cc; border-top-color: #059669;
animation: spin 1s ease-in-out infinite; animation: spin 1s ease-in-out infinite;
} }
@ -753,7 +768,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: #0066cc; background-color: #059669;
color: white; color: white;
border-radius: 4px; border-radius: 4px;
text-decoration: none; text-decoration: none;
@ -766,7 +781,7 @@
} }
.modal-choice-button:hover { .modal-choice-button:hover {
background-color: #0056b3; background-color:rgb(7, 119, 84);
} }
.modal-choice-button svg { .modal-choice-button svg {
@ -941,7 +956,6 @@
.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,6 +309,11 @@ 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 8 // Return CSS class based on index modulo 20
// This matches the CSS nth-child selectors in the timeline // This matches the CSS classes for up to 20 segments
return `segment-default-color segment-color-${(index % 8) + 1}`; return `segment-default-color segment-color-${(index % 20) + 1}`;
}; };
return ( return (

View File

@ -99,6 +99,7 @@
} }
.segment-thumbnail { .segment-thumbnail {
display: none;
width: 4rem; width: 4rem;
height: 2.25rem; height: 2.25rem;
background-size: cover; background-size: cover;
@ -129,7 +130,7 @@
margin-top: 0.25rem; margin-top: 0.25rem;
display: inline-block; display: inline-block;
background-color: #f3f4f6; background-color: #f3f4f6;
padding: 0 0.5rem; padding: 0;
border-radius: 0.25rem; border-radius: 0.25rem;
color: black; color: black;
} }
@ -169,28 +170,67 @@
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(59, 130, 246, 0.15); background-color: rgba(147, 179, 247, 0.2);
} }
.segment-color-2 { .segment-color-2 {
background-color: rgba(16, 185, 129, 0.15); background-color: rgba(129, 161, 243, 0.2);
} }
.segment-color-3 { .segment-color-3 {
background-color: rgba(245, 158, 11, 0.15); background-color: rgba(111, 143, 239, 0.2);
} }
.segment-color-4 { .segment-color-4 {
background-color: rgba(239, 68, 68, 0.15); background-color: rgba(93, 125, 237, 0.2);
} }
.segment-color-5 { .segment-color-5 {
background-color: rgba(139, 92, 246, 0.15); background-color: rgba(75, 107, 235, 0.2);
} }
.segment-color-6 { .segment-color-6 {
background-color: rgba(236, 72, 153, 0.15); background-color: rgba(65, 99, 235, 0.2);
} }
.segment-color-7 { .segment-color-7 {
background-color: rgba(6, 182, 212, 0.15); background-color: rgba(55, 91, 235, 0.2);
} }
.segment-color-8 { .segment-color-8 {
background-color: rgba(250, 204, 21, 0.15); background-color: rgba(45, 83, 235, 0.2);
}
.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,4 +1,16 @@
#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;
@ -11,6 +23,8 @@
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 {
@ -20,7 +34,7 @@
} }
.timeline-title-text { .timeline-title-text {
font-weight: 700; font-size: 0.875rem;
} }
.current-time { .current-time {
@ -48,10 +62,11 @@
.timeline-container { .timeline-container {
position: relative; position: relative;
min-width: 100%; min-width: 100%;
background-color: #fafbfc; background-color: #eff6ff;
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 {
@ -194,7 +209,7 @@
left: 0; left: 0;
right: 0; right: 0;
padding: 0.4rem; padding: 0.4rem;
background-color: rgba(0, 0, 0, 0.4); background-color: rgba(59, 130, 246, 0.6);
color: white; color: white;
opacity: 1; opacity: 1;
transition: background-color 0.2s; transition: background-color 0.2s;
@ -202,15 +217,15 @@
} }
.clip-segment:hover .clip-segment-info { .clip-segment:hover .clip-segment-info {
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(59, 130, 246, 0.7);
} }
.clip-segment.selected .clip-segment-info { .clip-segment.selected .clip-segment-info {
background-color: rgba(59, 130, 246, 0.5); background-color: rgba(37, 99, 235, 0.8);
} }
.clip-segment.selected:hover .clip-segment-info { .clip-segment.selected:hover .clip-segment-info {
background-color: rgba(59, 130, 246, 0.4); background-color: rgba(37, 99, 235, 0.75);
} }
.clip-segment-name { .clip-segment-name {

View File

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

View File

@ -27,3 +27,39 @@ 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
```

9
frontend/jest.config.js Normal file
View File

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

View File

@ -1,57 +1,69 @@
{ {
"name": "mediacms-frontend", "name": "mediacms-frontend",
"version": "0.9.1", "version": "0.9.2",
"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",
"browserslist": [ "test-coverage": "npx rimraf ./coverage && jest --coverage",
"cover 99.5%" "test-coverage-watch": "npm run test-coverage -- --watchAll",
], "test-watch": "jest --watch"
"devDependencies": { },
"@babel/core": "^7.26.9", "browserslist": [
"@babel/preset-env": "^7.26.9", "cover 99.5%"
"@babel/preset-react": "^7.26.3", ],
"@types/minimatch": "^5.1.2", "devDependencies": {
"@types/react": "^19.0.10", "@babel/core": "^7.26.9",
"@types/react-dom": "^19.0.4", "@babel/preset-env": "^7.26.9",
"autoprefixer": "^10.4.21", "@babel/preset-react": "^7.26.3",
"babel-loader": "^10.0.0", "@types/flux": "^3.1.15",
"compass-mixins": "^0.12.12", "@types/jest": "^29.5.12",
"copy-webpack-plugin": "^13.0.0", "@types/minimatch": "^5.1.2",
"core-js": "^3.41.0", "@types/react": "^19.0.10",
"css-loader": "^7.1.2", "@types/react-dom": "^19.0.4",
"dotenv": "^16.4.7", "@types/url-parse": "^1.4.11",
"ejs": "^3.1.10", "autoprefixer": "^10.4.21",
"ejs-compiled-loader": "^3.1.0", "babel-jest": "^30.2.0",
"mediacms-scripts": "file:packages/scripts", "babel-loader": "^10.0.0",
"postcss-loader": "^8.1.1", "compass-mixins": "^0.12.12",
"prettier": "^3.5.3", "copy-webpack-plugin": "^13.0.0",
"prop-types": "^15.8.1", "core-js": "^3.41.0",
"sass": "^1.85.1", "css-loader": "^7.1.2",
"sass-loader": "^16.0.5", "dotenv": "^16.4.7",
"ts-loader": "^9.5.2", "ejs": "^3.1.10",
"typescript": "^5.8.2", "ejs-compiled-loader": "^3.1.0",
"url-loader": "^4.1.1", "jest": "^29.7.0",
"webpack": "^5.98.0" "jest-environment-jsdom": "^30.2.0",
}, "jsdom": "^27.3.0",
"dependencies": { "mediacms-scripts": "file:packages/scripts",
"@react-pdf-viewer/core": "^3.9.0", "postcss-loader": "^8.1.1",
"@react-pdf-viewer/default-layout": "^3.9.0", "prettier": "^3.5.3",
"axios": "^1.8.2", "prop-types": "^15.8.1",
"flux": "^4.0.4", "sass": "^1.85.1",
"normalize.css": "^8.0.1", "sass-loader": "^16.0.5",
"pdfjs-dist": "3.4.120", "ts-jest": "^29.2.5",
"react": "^17.0.2", "ts-loader": "^9.5.2",
"react-dom": "^17.0.2", "typescript": "^5.9.3",
"react-mentions": "^4.3.1", "url-loader": "^4.1.1",
"sortablejs": "^1.13.0", "webpack": "^5.98.0"
"timeago.js": "^4.0.2", },
"url-parse": "^1.5.10" "dependencies": {
} "@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,10 +9,29 @@
.bulk-actions-container { .bulk-actions-container {
display: flex; display: flex;
align-items: center; align-items: flex-start;
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,6 +2,9 @@ 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 {
@ -17,6 +20,7 @@ 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> = ({
@ -32,19 +36,35 @@ 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 && (
<div className="bulk-actions-container"> <LinksConsumer>
<BulkActionsDropdown selectedCount={selectedCount} onActionSelect={onBulkAction} /> {(links) => (
<SelectAllCheckbox <div className="bulk-actions-container">
totalCount={totalCount} <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
selectedCount={selectedCount} <BulkActionsDropdown selectedCount={selectedCount} onActionSelect={onBulkAction} />
onSelectAll={onSelectAll} <SelectAllCheckbox
onDeselectAll={onDeselectAll} totalCount={totalCount}
/> selectedCount={selectedCount}
</div> onSelectAll={onSelectAll}
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()}
<a href={props.link} title="" className="view-full-playlist"> <span className="view-full-playlist">
VIEW FULL PLAYLIST VIEW FULL PLAYLIST
</a> </span>
</UnderThumbWrapper> </UnderThumbWrapper>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -3,257 +3,278 @@ 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, publishedOnDate } from '../../utils/helpers/'; import { formatInnerLink, inEmbeddedApp, 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;
} }
const timeRegex = new RegExp('((\\d)?\\d:)?(\\d)?\\d:\\d\\d', 'g'); function cancelMediaRemoval() {
return text.replace(timeRegex, wrapTimestampWithAnchor); popupContentRef.current.toggle();
} }
return ( function onMediaDelete(mediaId) {
<div className="media-info-content"> // FIXME: Without delay creates conflict [ Uncaught Error: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. ].
{void 0 === PageStore.get('config-media-item').displayAuthor || setTimeout(function () {
null === PageStore.get('config-media-item').displayAuthor || PageActions.addNotification('Media removed. Redirecting...', 'mediaDelete');
!!PageStore.get('config-media-item').displayAuthor ? ( setTimeout(function () {
<MediaAuthorBanner link={authorLink} thumb={authorThumb} name={props.author.name} published={props.published} /> window.location.href =
) : null} SiteContext._currentValue.url +
'/' +
MediaPageStore.get('media-data').author_profile.replace(/^\//g, '');
}, 2000);
}, 100);
<div className="media-content-banner"> if (void 0 !== mediaId) {
<div className="media-content-banner-inner"> console.info("Removed media '" + mediaId + '"');
{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 ? ( function onMediaDeleteFail(mediaId) {
<div className="media-author-actions"> // FIXME: Without delay creates conflict [ Uncaught Error: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. ].
{userCan.editMedia ? <EditMediaButton link={MediaPageStore.get('media-data').edit_url} /> : null} setTimeout(function () {
PageActions.addNotification('Media removal failed', 'mediaDeleteFail');
}, 100);
{userCan.deleteMedia ? ( if (void 0 !== mediaId) {
<PopupTrigger contentRef={popupContentRef}> console.info('Media "' + mediaId + '"' + ' removal failed');
<button className="remove-media-icon" title={translateString('Delete media')}> }
<i className="material-icons">delete</i> }
</button>
</PopupTrigger>
) : null}
{userCan.deleteMedia ? ( function onClickLoadMore() {
<PopupContent contentRef={popupContentRef}> setIsContentVisible(!isContentVisible);
<PopupMain> }
<div className="popup-message">
<span className="popup-message-title">Media removal</span> useEffect(() => {
<span className="popup-message-main">You're willing to remove media permanently?</span> MediaPageStore.on('media_delete', onMediaDelete);
</div> MediaPageStore.on('media_delete_fail', onMediaDeleteFail);
<hr /> return () => {
<span className="popup-message-bottom"> MediaPageStore.removeListener('media_delete', onMediaDelete);
<button className="button-link cancel-comment-removal" onClick={cancelMediaRemoval}> MediaPageStore.removeListener('media_delete_fail', onMediaDeleteFail);
CANCEL };
</button> }, []);
<button className="button-link proceed-comment-removal" onClick={proceedMediaRemoval}>
PROCEED const authorLink = formatInnerLink(props.author.url, SiteContext._currentValue.url);
</button> const authorThumb = formatInnerLink(props.author.thumb, SiteContext._currentValue.url);
</span>
</PopupMain> function setTimestampAnchors(text) {
</PopupContent> function wrapTimestampWithAnchor(match, string) {
) : null} 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;
}
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}
</div>
</div>
<CommentsList /> {!inEmbeddedApp() && <CommentsList />}
</div> </div>
); );
} }

View File

@ -108,7 +108,9 @@ 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 mediaState = MediaPageStore.get('media-data').state; const mediaData = MediaPageStore.get('media-data');
const mediaState = mediaData.state;
const isShared = mediaData.is_shared;
let stateTooltip = ''; let stateTooltip = '';
@ -121,6 +123,8 @@ 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
@ -129,15 +133,28 @@ 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}
{'public' !== mediaState ? ( {isShared || 'public' !== mediaState ? (
<div className="media-labels-area"> <div className="media-labels-area">
<div className="media-labels-area-inner"> <div className="media-labels-area-inner">
<span className="media-label-state"> {isShared ? (
<span>{mediaState}</span> <>
</span> <span className="media-label-state">
<span className="helper-icon" data-tooltip={stateTooltip}> <span>shared</span>
<i className="material-icons">help_outline</i> </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>
</div> </div>
) : null} ) : null}

View File

@ -1,90 +1,119 @@
import React from 'react'; import React from 'react';
import { formatViewsNumber } from '../../utils/helpers/'; import { formatViewsNumber, inEmbeddedApp } 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 { MediaLikeIcon, MediaDislikeIcon, OtherMediaDownloadLink, VideoMediaDownloadLink, MediaSaveButton, MediaShareButton, MediaMoreOptionsIcon } from '../media-actions/'; import {
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 mediaState = MediaPageStore.get('media-data').state; const mediaData = MediaPageStore.get('media-data');
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.downloadFilename} />
)}
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
</div>
</div>
</div>
</div>
);
}
} }

View File

@ -1,28 +1,33 @@
.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 & {
padding-left: var(--sidebar-width); #page-media {
opacity: 1; padding-left: 0;
}
} }
}
.visible-sidebar #page-media & { body.sliding-sidebar & {
padding-left: 0; transition-property: padding-left;
} transition-duration: 0.2s;
.visible-sidebar & {
#page-media {
padding-left: 0;
} }
}
body.sliding-sidebar & { .embedded-app & {
transition-property: padding-left; padding-top: 0;
transition-duration: 0.2s; padding-left: 0;
} }
} }
#page-profile-media, #page-profile-media,
@ -30,20 +35,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,
@ -51,7 +56,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,6 +32,7 @@ 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 } from '../utils/helpers/'; import { formatInnerLink, csrfToken, postRequest, inEmbeddedApp } 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" /> <ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="about" hideChannelBanner={inEmbeddedApp()} />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent" enabledContactForm={this.enabledContactForm}> <ProfilePagesContent key="ProfilePagesContent" enabledContactForm={this.enabledContactForm}>

View File

@ -2,6 +2,7 @@ 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';
@ -28,7 +29,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" /> <ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="history" hideChannelBanner={inEmbeddedApp()} />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent"> <ProfilePagesContent key="ProfilePagesContent">

View File

@ -2,6 +2,7 @@ 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';
@ -28,7 +29,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" /> <ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="liked" hideChannelBanner={inEmbeddedApp()} />
) : 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,6 +1,7 @@
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';
@ -30,7 +31,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" /> <ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="playlists" hideChannelBanner={inEmbeddedApp()} />
) : 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 { translateString } from '../utils/helpers'; import { inEmbeddedApp, 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,400 +19,443 @@ 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"> <div className="start-uploading">Media that you have shared with others will show up here.</div>
Media that you have shared with others will show up here. </div>
</div> )}
</div> </LinksConsumer>
)} );
</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;
}
} }
this.setState({ componentDidMount() {
author: author, ProfilePageActions.load_author_data();
requestUrl: requestUrl, }
});
}
onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) { authorDataLoad() {
this.setState({ const author = ProfilePageStore.get('author-data');
uploadsPreviewItemsCount: totalAuthorPreviewItems,
});
}
getCountFunc(count) { let requestUrl = this.state.requestUrl;
this.setState(
{
channelMediaCount: count,
},
() => {
if (this.state.query) {
let title = '';
if (!count) { if (author) {
title = 'No results for "' + this.state.query + '"'; if (this.state.query) {
} else if (1 === count) { requestUrl =
title = '1 result for "' + this.state.query + '"'; ApiUrlContext._currentValue.media +
} else { '?author=' +
title = count + ' results for "' + this.state.query + '"'; author.id +
} '&show=shared_by_me&q=' +
encodeURIComponent(this.state.query) +
this.setState({ this.state.filterArgs;
title: title, } else {
}); requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
author.id +
'&show=shared_by_me' +
this.state.filterArgs;
}
} }
}
);
}
changeRequestQuery(newQuery) { this.setState({
if (!this.state.author) { author: author,
return; requestUrl: requestUrl,
});
} }
let requestUrl; onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) {
this.setState({
if (newQuery) { uploadsPreviewItemsCount: totalAuthorPreviewItems,
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;
} }
let title = this.state.title; getCountFunc(count) {
this.setState(
{
channelMediaCount: count,
},
() => {
if (this.state.query) {
let title = '';
if ('' === newQuery) { if (!count) {
title = this.props.title; title = 'No results for "' + this.state.query + '"';
} else if (1 === count) {
title = '1 result for "' + this.state.query + '"';
} else {
title = count + ' results for "' + this.state.query + '"';
}
this.setState({
title: title,
});
}
}
);
} }
this.setState({ changeRequestQuery(newQuery) {
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 (this.state.query) { if (newQuery) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_by_me&q=' +
encodeURIComponent(newQuery) +
this.state.filterArgs;
} else { } else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me' + this.state.filterArgs; requestUrl =
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 });
} }
}
pageContent() { onToggleFiltersClick() {
const authorData = ProfilePageStore.get('author-data'); this.setState({
hiddenFilters: !this.state.hiddenFilters,
hiddenTags: true,
hiddenSorting: true,
});
}
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username; onToggleTagsClick() {
this.setState({
hiddenFilters: true,
hiddenTags: !this.state.hiddenTags,
hiddenSorting: true,
});
}
// Check if any filters are active onToggleSortingClick() {
const hasActiveFilters = this.state.filterArgs && ( this.setState({
this.state.filterArgs.includes('media_type=') || hiddenFilters: true,
this.state.filterArgs.includes('upload_date=') || hiddenTags: true,
this.state.filterArgs.includes('duration=') || hiddenSorting: !this.state.hiddenSorting,
this.state.filterArgs.includes('publish_state=') });
); }
return [ onTagSelect(tag) {
this.state.author ? ( this.setState({ selectedTag: tag }, () => {
<ProfilePagesHeader this.onFiltersUpdate({
key="ProfilePagesHeader" media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
author={this.state.author} upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
type="shared_by_me" duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
onQueryChange={this.changeRequestQuery} publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
onToggleFiltersClick={this.onToggleFiltersClick} sort_by: this.state.selectedSort,
onToggleTagsClick={this.onToggleTagsClick} tag: tag,
onToggleSortingClick={this.onToggleSortingClick} });
hasActiveFilters={hasActiveFilters} });
hasActiveTags={this.state.selectedTag !== 'all'} }
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
/> onSortSelect(sortBy) {
) : null, this.setState({ selectedSort: sortBy }, () => {
this.state.author ? ( this.onFiltersUpdate({
<ProfilePagesContent key="ProfilePagesContent"> media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
<MediaListWrapper upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
title={this.state.title} duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
className="items-list-ver" publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
showBulkActions={isMediaAuthor} sort_by: sortBy,
selectedCount={this.props.bulkActions.selectedMedia.size} tag: this.state.selectedTag,
totalCount={this.props.bulkActions.availableMediaIds.length} });
onBulkAction={this.props.bulkActions.handleBulkAction} });
onSelectAll={this.props.bulkActions.handleSelectAll} }
onDeselectAll={this.props.bulkActions.handleDeselectAll}
> onFiltersUpdate(updatedArgs) {
<ProfileMediaFilters hidden={this.state.hiddenFilters} tags={this.state.availableTags} onFiltersUpdate={this.onFiltersUpdate} /> const args = {
<ProfileMediaTags hidden={this.state.hiddenTags} tags={this.state.availableTags} onTagSelect={this.onTagSelect} /> media_type: null,
<ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} /> upload_date: null,
<LazyLoadItemListAsync duration: null,
key={`${this.state.requestUrl}-${this.props.bulkActions.listKey}`} publish_state: null,
requestUrl={this.state.requestUrl} sort_by: null,
hideAuthor={true} ordering: null,
itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null} t: null,
hideViews={!PageStore.get('config-media-item').displayViews} };
hideDate={!PageStore.get('config-media-item').displayPublishDate}
canEdit={isMediaAuthor} switch (updatedArgs.media_type) {
onResponseDataLoaded={this.onResponseDataLoaded} case 'video':
showSelection={isMediaAuthor} case 'audio':
hasAnySelection={this.props.bulkActions.selectedMedia.size > 0} case 'image':
selectedMedia={this.props.bulkActions.selectedMedia} case 'pdf':
onMediaSelection={this.props.bulkActions.handleMediaSelection} args.media_type = updatedArgs.media_type;
onItemsUpdate={this.props.bulkActions.handleItemsUpdate} break;
/> }
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
<EmptySharedByMe name={this.state.author.name} /> switch (updatedArgs.upload_date) {
) : null} case 'today':
</MediaListWrapper> case 'this_week':
</ProfilePagesContent> case 'this_month':
) : null, case 'this_year':
this.state.author && isMediaAuthor ? ( args.upload_date = updatedArgs.upload_date;
<BulkActionsModals break;
key="BulkActionsModals" }
{...this.props.bulkActions}
selectedMediaIds={Array.from(this.props.bulkActions.selectedMedia)} // Handle duration filter
csrfToken={this.props.bulkActions.getCsrfToken()} if (updatedArgs.duration && updatedArgs.duration !== 'all') {
username={this.state.author.username} args.duration = updatedArgs.duration;
onConfirmCancel={this.props.bulkActions.handleConfirmCancel} }
onConfirmProceed={this.props.bulkActions.handleConfirmProceed}
onPermissionModalCancel={this.props.bulkActions.handlePermissionModalCancel} // Handle publish state filter
onPermissionModalSuccess={this.props.bulkActions.handlePermissionModalSuccess} if (updatedArgs.publish_state && updatedArgs.publish_state !== 'all') {
onPermissionModalError={this.props.bulkActions.handlePermissionModalError} args.publish_state = updatedArgs.publish_state;
onPlaylistModalCancel={this.props.bulkActions.handlePlaylistModalCancel} }
onPlaylistModalSuccess={this.props.bulkActions.handlePlaylistModalSuccess}
onPlaylistModalError={this.props.bulkActions.handlePlaylistModalError} switch (updatedArgs.sort_by) {
onChangeOwnerModalCancel={this.props.bulkActions.handleChangeOwnerModalCancel} case 'date_added_desc':
onChangeOwnerModalSuccess={this.props.bulkActions.handleChangeOwnerModalSuccess} // Default sorting, no need to add parameters
onChangeOwnerModalError={this.props.bulkActions.handleChangeOwnerModalError} break;
onPublishStateModalCancel={this.props.bulkActions.handlePublishStateModalCancel} case 'date_added_asc':
onPublishStateModalSuccess={this.props.bulkActions.handlePublishStateModalSuccess} args.ordering = 'asc';
onPublishStateModalError={this.props.bulkActions.handlePublishStateModalError} break;
onCategoryModalCancel={this.props.bulkActions.handleCategoryModalCancel} case 'alphabetically_asc':
onCategoryModalSuccess={this.props.bulkActions.handleCategoryModalSuccess} args.sort_by = 'title_asc';
onCategoryModalError={this.props.bulkActions.handleCategoryModalError} break;
onTagModalCancel={this.props.bulkActions.handleTagModalCancel} case 'alphabetically_desc':
onTagModalSuccess={this.props.bulkActions.handleTagModalSuccess} args.sort_by = 'title_desc';
onTagModalError={this.props.bulkActions.handleTagModalError} break;
/> case 'plays_least':
) : null, 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_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,364 +10,404 @@ 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 { translateString } from '../utils/helpers'; import { inEmbeddedApp, 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"> <div className="start-uploading">Media that others have shared with you will show up here.</div>
Media that others have shared with you will show up here. </div>
</div> )}
</div> </LinksConsumer>
)} );
</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;
}
} }
this.setState({ componentDidMount() {
author: author, ProfilePageActions.load_author_data();
requestUrl: requestUrl, }
});
}
onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) { authorDataLoad() {
this.setState({ const author = ProfilePageStore.get('author-data');
uploadsPreviewItemsCount: totalAuthorPreviewItems,
});
}
getCountFunc(count) { let requestUrl = this.state.requestUrl;
this.setState(
{
channelMediaCount: count,
},
() => {
if (this.state.query) {
let title = '';
if (!count) { if (author) {
title = 'No results for "' + this.state.query + '"'; if (this.state.query) {
} else if (1 === count) { requestUrl =
title = '1 result for "' + this.state.query + '"'; ApiUrlContext._currentValue.media +
} else { '?author=' +
title = count + ' results for "' + this.state.query + '"'; author.id +
} '&show=shared_with_me&q=' +
encodeURIComponent(this.state.query) +
this.setState({ this.state.filterArgs;
title: title, } else {
}); requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
author.id +
'&show=shared_with_me' +
this.state.filterArgs;
}
} }
}
);
}
changeRequestQuery(newQuery) { this.setState({
if (!this.state.author) { author: author,
return; requestUrl: requestUrl,
});
} }
let requestUrl; onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) {
this.setState({
if (newQuery) { uploadsPreviewItemsCount: totalAuthorPreviewItems,
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;
} }
let title = this.state.title; getCountFunc(count) {
this.setState(
{
channelMediaCount: count,
},
() => {
if (this.state.query) {
let title = '';
if ('' === newQuery) { if (!count) {
title = this.props.title; title = 'No results for "' + this.state.query + '"';
} else if (1 === count) {
title = '1 result for "' + this.state.query + '"';
} else {
title = count + ' results for "' + this.state.query + '"';
}
this.setState({
title: title,
});
}
}
);
} }
this.setState({ changeRequestQuery(newQuery) {
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 (this.state.query) { if (newQuery) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_with_me&q=' +
encodeURIComponent(newQuery) +
this.state.filterArgs;
} else { } else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me' + this.state.filterArgs; requestUrl =
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 });
} }
}
pageContent() { onToggleFiltersClick() {
const authorData = ProfilePageStore.get('author-data'); this.setState({
hiddenFilters: !this.state.hiddenFilters,
hiddenTags: true,
hiddenSorting: true,
});
}
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username; onToggleTagsClick() {
this.setState({
hiddenFilters: true,
hiddenTags: !this.state.hiddenTags,
hiddenSorting: true,
});
}
// Check if any filters are active onToggleSortingClick() {
const hasActiveFilters = this.state.filterArgs && ( this.setState({
this.state.filterArgs.includes('media_type=') || hiddenFilters: true,
this.state.filterArgs.includes('upload_date=') || hiddenTags: true,
this.state.filterArgs.includes('duration=') || hiddenSorting: !this.state.hiddenSorting,
this.state.filterArgs.includes('publish_state=') });
); }
return [ onTagSelect(tag) {
this.state.author ? ( this.setState({ selectedTag: tag }, () => {
<ProfilePagesHeader this.onFiltersUpdate({
key="ProfilePagesHeader" media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
author={this.state.author} upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
type="shared_with_me" duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
onQueryChange={this.changeRequestQuery} publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
onToggleFiltersClick={this.onToggleFiltersClick} sort_by: this.state.selectedSort,
onToggleTagsClick={this.onToggleTagsClick} tag: tag,
onToggleSortingClick={this.onToggleSortingClick} });
hasActiveFilters={hasActiveFilters} });
hasActiveTags={this.state.selectedTag !== 'all'} }
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
/> onSortSelect(sortBy) {
) : null, this.setState({ selectedSort: sortBy }, () => {
this.state.author ? ( this.onFiltersUpdate({
<ProfilePagesContent key="ProfilePagesContent"> media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
<MediaListWrapper upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
title={this.state.title} duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
className="items-list-ver" publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
> sort_by: sortBy,
<ProfileMediaFilters hidden={this.state.hiddenFilters} tags={this.state.availableTags} onFiltersUpdate={this.onFiltersUpdate} /> tag: this.state.selectedTag,
<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} onFiltersUpdate(updatedArgs) {
hideAuthor={true} const args = {
itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null} media_type: null,
hideViews={!PageStore.get('config-media-item').displayViews} upload_date: null,
hideDate={!PageStore.get('config-media-item').displayPublishDate} duration: null,
canEdit={false} publish_state: null,
onResponseDataLoaded={this.onResponseDataLoaded} sort_by: null,
/> ordering: null,
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? ( t: null,
<EmptySharedWithMe name={this.state.author.name} /> };
) : null}
</MediaListWrapper> switch (updatedArgs.media_type) {
</ProfilePagesContent> case 'video':
) : null, 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) {
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,6 +1,7 @@
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';
@ -10,102 +11,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" />,
this.state.pagePlaylistLoaded ? ( !inEmbeddedApp() && 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,
] ]
: [ : [
this.state.pagePlaylistLoaded ? ( !inEmbeddedApp() && 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,6 +2,7 @@ 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';
@ -11,118 +12,119 @@ 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,
});
} }
}
onViewerModeChange() { componentDidMount() {
this.setState({ theaterMode: VideoViewerStore.get('in-theater-mode') }); MediaPageActions.loadMediaData();
} // FIXME: Is not neccessary to check on every window dimension for changes...
PageStore.on('window_resize', this.onWindowResize);
}
onMediaLoadError(a) { onWindowResize() {
this.setState({ mediaLoadFailed: true }); this.setState({
} wideLayout: wideLayoutBreakpoint <= window.innerWidth,
});
}
pageContent() { onPagePlaylistLoad() {
const viewerClassname = 'cf viewer-section' + (this.state.theaterMode ? ' theater-mode' : ' viewer-wide'); this.setState({
const viewerNestedClassname = 'viewer-section-nested' + (this.state.theaterMode ? ' viewer-section' : ''); pagePlaylistLoaded: true,
pagePlaylistData: MediaPageStore.get('playlist-data'),
});
}
return this.state.mediaLoadFailed ? ( onMediaLoad() {
<div className={viewerClassname}> const isVideoMedia =
<ViewerError /> 'video' === MediaPageStore.get('media-type') || 'audio' === MediaPageStore.get('media-type');
</div>
) : ( if (isVideoMedia) {
<div className={viewerClassname}> this.onViewerModeChange = this.onViewerModeChange.bind(this);
{[
<div className="viewer-container" key="viewer-container"> VideoViewerStore.on('changed_viewer_mode', this.onViewerModeChange);
{this.state.mediaLoaded && this.state.pagePlaylistLoaded
? this.viewerContainerContent(MediaPageStore.get('media-data')) this.setState({
: null} mediaLoaded: true,
</div>, isVideoMedia: isVideoMedia,
<div key="viewer-section-nested" className={viewerNestedClassname}> theaterMode: VideoViewerStore.get('in-theater-mode'),
{!this.state.wideLayout || (this.state.isVideoMedia && this.state.theaterMode) });
? [ } else {
<ViewerInfoVideo key="viewer-info" />, this.setState({
this.state.pagePlaylistLoaded ? ( mediaLoaded: true,
<ViewerSidebar isVideoMedia: isVideoMedia,
key="viewer-sidebar" });
mediaId={MediaPageStore.get('media-id')} }
playlistData={MediaPageStore.get('playlist-data')} }
/>
) : null, onViewerModeChange() {
] this.setState({ theaterMode: VideoViewerStore.get('in-theater-mode') });
: [ }
this.state.pagePlaylistLoaded ? (
<ViewerSidebar onMediaLoadError(a) {
key="viewer-sidebar" this.setState({ mediaLoadFailed: true });
mediaId={MediaPageStore.get('media-id')} }
playlistData={MediaPageStore.get('playlist-data')}
/> pageContent() {
) : null, const viewerClassname = 'cf viewer-section' + (this.state.theaterMode ? ' theater-mode' : ' viewer-wide');
<ViewerInfoVideo key="viewer-info" />, const viewerNestedClassname = 'viewer-section-nested' + (this.state.theaterMode ? ' viewer-section' : '');
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,101 +1,103 @@
import React, { createContext, useContext, useEffect, useState } from 'react'; import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { BrowserCache } from '../classes/'; import { BrowserCache } from '../classes/';
import { PageStore } from '../stores/'; import { PageStore } from '../stores/';
import { addClassname, removeClassname } from '../helpers/'; import { addClassname, removeClassname, inEmbeddedApp } 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 () {
slidingSidebarTimeout = null; if ('media' === PageStore.get('current-page')) {
removeClassname(document.body, 'sliding-sidebar'); if (visibleSidebar) {
}, 220); addClassname(document.body, 'overflow-hidden');
}, 20); } 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 = 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 enabledSidebar = !!(document.getElementById('app-sidebar') || document.querySelector('.page-sidebar')); const isMediaPage = useMemo(() => PageStore.get('current-page') === 'media', []);
const isEmbeddedApp = useMemo(() => inEmbeddedApp(), []);
const [visibleSidebar, setVisibleSidebar] = useState(cache.get('visible-sidebar')); const enabledSidebar = Boolean(document.getElementById('app-sidebar') || document.querySelector('.page-sidebar'));
const [visibleMobileSearch, setVisibleMobileSearch] = useState(false);
const toggleMobileSearch = () => { const [visibleSidebar, setVisibleSidebar] = useState(cache.get('visible-sidebar'));
setVisibleMobileSearch(!visibleMobileSearch); const [visibleMobileSearch, setVisibleMobileSearch] = useState(false);
};
const toggleSidebar = () => { const toggleMobileSearch = () => {
const newval = !visibleSidebar; setVisibleMobileSearch(!visibleMobileSearch);
onSidebarVisibilityChange(newval); };
setVisibleSidebar(newval);
};
useEffect(() => { const toggleSidebar = () => {
if (visibleSidebar) { const newval = !visibleSidebar;
addClassname(document.body, 'visible-sidebar'); onSidebarVisibilityChange(newval);
} else { setVisibleSidebar(newval);
removeClassname(document.body, 'visible-sidebar'); };
}
if ('media' !== PageStore.get('current-page') && 1023 < window.innerWidth) {
cache.set('visible-sidebar', visibleSidebar);
}
}, [visibleSidebar]);
useEffect(() => { useEffect(() => {
PageStore.once('page_init', () => { if (!isEmbeddedApp && visibleSidebar) {
if ('media' === PageStore.get('current-page')) { addClassname(document.body, 'visible-sidebar');
setVisibleSidebar(false); } else {
removeClassname(document.body, 'visible-sidebar'); removeClassname(document.body, 'visible-sidebar');
} }
});
setVisibleSidebar( if (!isEmbeddedApp && !isMediaPage && 1023 < window.innerWidth) {
'media' !== PageStore.get('current-page') && cache.set('visible-sidebar', visibleSidebar);
1023 < window.innerWidth && }
(null === visibleSidebar || visibleSidebar) }, [isEmbeddedApp, isMediaPage, visibleSidebar]);
);
}, []);
const value = { useEffect(() => {
enabledSidebar, PageStore.once('page_init', () => {
visibleSidebar, if (isEmbeddedApp || isMediaPage) {
setVisibleSidebar, setVisibleSidebar(false);
visibleMobileSearch, removeClassname(document.body, 'visible-sidebar');
toggleMobileSearch, }
toggleSidebar, });
};
return <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>; setVisibleSidebar(
!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

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

View File

@ -1,8 +1,15 @@
// check templates/config/installation/translations.html for more // check templates/config/installation/translations.html for more
export function replaceString(string) { export function replaceString(word) {
for (const key in window.REPLACEMENTS) { if (!window.REPLACEMENTS) {
string = string.replace(key, window.REPLACEMENTS[key]); return word;
} }
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) { if (callback instanceof Function || typeof callback === 'function') {
callback(result); callback(result);
} }
} }
function errorHandler(error) { function errorHandler(error) {
if (errorCallback instanceof Function) { if (errorCallback instanceof Function || typeof errorCallback === '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) { if (callback instanceof Function || typeof callback === 'function') {
callback(result); callback(result);
} }
} }
function errorHandler(error) { function errorHandler(error) {
if (errorCallback instanceof Function) { if (errorCallback instanceof Function || typeof errorCallback === '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) { if (callback instanceof Function || typeof callback === 'function') {
callback(result); callback(result);
} }
} }
function errorHandler(error) { function errorHandler(error) {
if (errorCallback instanceof Function) { if (errorCallback instanceof Function || typeof errorCallback === '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) { if (callback instanceof Function || typeof callback === 'function') {
callback(result); callback(result);
} }
} }
function errorHandler(error) { function errorHandler(error) {
if (errorCallback instanceof Function) { if (errorCallback instanceof Function || typeof errorCallback === 'function') {
errorCallback(error); errorCallback(error);
} }
} }

View File

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

View File

@ -3,64 +3,83 @@ 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) {
const appHeader = document.getElementById('app-header'); if (inEmbeddedApp()) {
const appSidebar = document.getElementById('app-sidebar'); globalThis.document.body.classList.add('embedded-app');
const appContent = idSelector ? document.getElementById(idSelector) : undefined; globalThis.document.body.classList.remove('visible-sidebar');
if (appContent && PageComponent) { const appContent = idSelector ? document.getElementById(idSelector) : undefined;
ReactDOM.render(
<AppProviders> if (appContent && PageComponent) {
{appHeader ? ReactDOM.createPortal(<PageHeader />, appHeader) : null} ReactDOM.render(
{appSidebar ? ReactDOM.createPortal(<PageSidebar />, appSidebar) : null} <AppProviders>
<PageComponent /> <PageComponent />
</AppProviders>, </AppProviders>,
appContent appContent
); );
} else if (appHeader && appSidebar) { }
ReactDOM.render(
<AppProviders> return;
{ReactDOM.createPortal(<PageHeader />, appHeader)} }
<PageSidebar />
</AppProviders>, const appContent = idSelector ? document.getElementById(idSelector) : undefined;
appSidebar const appHeader = document.getElementById('app-header');
); const appSidebar = document.getElementById('app-sidebar');
} else if (appHeader) {
ReactDOM.render( if (appContent && PageComponent) {
<LayoutProvider> ReactDOM.render(
<ThemeProvider> <AppProviders>
<UserProvider> {appHeader ? ReactDOM.createPortal(<PageHeader />, appHeader) : null}
<PageHeader /> {appSidebar ? ReactDOM.createPortal(<PageSidebar />, appSidebar) : null}
</UserProvider> <PageComponent />
</ThemeProvider> </AppProviders>,
</LayoutProvider>, appContent
appSidebar );
); } else if (appHeader && appSidebar) {
} else if (appSidebar) { ReactDOM.render(
ReactDOM.render( <AppProviders>
<AppProviders> {ReactDOM.createPortal(<PageHeader />, appHeader)}
<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

@ -0,0 +1,56 @@
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

@ -0,0 +1,220 @@
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

@ -0,0 +1,47 @@
// 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

@ -0,0 +1,44 @@
// 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

@ -0,0 +1,23 @@
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

@ -0,0 +1,15 @@
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

@ -0,0 +1,106 @@
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

@ -0,0 +1,47 @@
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

@ -0,0 +1,54 @@
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

@ -0,0 +1,156 @@
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

@ -0,0 +1,111 @@
// 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

@ -0,0 +1,33 @@
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

@ -0,0 +1,45 @@
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

@ -0,0 +1,68 @@
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');
});
});
});
});

View File

@ -0,0 +1,218 @@
import axios from 'axios';
import { getRequest, postRequest, putRequest, deleteRequest } from '../../../src/static/js/utils/helpers/requests';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('js/utils/helpers', () => {
describe('requests', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('getRequest', () => {
const url = '/api/test';
test('Calls axios.get with url and default config (async mode)', () => {
mockedAxios.get.mockResolvedValueOnce({ data: 'ok' } as any);
const cb = jest.fn();
getRequest(url, false, cb, undefined);
expect(mockedAxios.get).toHaveBeenCalledWith(url, {
timeout: null,
maxContentLength: null,
});
});
test('Invokes callback when provided (async mode)', async () => {
const response = { data: 'ok' } as any;
mockedAxios.get.mockResolvedValueOnce(response);
const cb = jest.fn();
await getRequest(url, true, cb, undefined);
expect(cb).toHaveBeenCalledWith(response);
});
// @todo: Revisit this behavior
test('Does not throw when callback is not a function', async () => {
mockedAxios.get.mockResolvedValueOnce({ data: 'ok' } as any);
await expect(getRequest(url, true, undefined as any, undefined as any)).resolves.toBeUndefined();
});
test('Error handler wraps network errors with type network', async () => {
const networkError = new Error('Network Error');
mockedAxios.get.mockRejectedValueOnce(networkError);
const errorCb = jest.fn();
await getRequest(url, true, undefined, errorCb);
expect(errorCb).toHaveBeenCalledTimes(1);
const arg = errorCb.mock.calls[0][0];
expect(arg).toStrictEqual({ type: 'network', error: networkError });
});
test('Error handler maps status 401 to private error', async () => {
const error = { response: { status: 401 } };
mockedAxios.get.mockRejectedValueOnce(error);
const errorCb = jest.fn();
await getRequest(url, true, undefined, errorCb);
expect(errorCb).toHaveBeenCalledWith({
type: 'private',
error,
message: 'Media is private',
});
});
test('Error handler maps status 400 to unavailable error', async () => {
const error = { response: { status: 400 } };
mockedAxios.get.mockRejectedValueOnce(error);
const errorCb = jest.fn();
await getRequest(url, true, undefined, errorCb);
expect(errorCb).toHaveBeenCalledWith({
type: 'unavailable',
error,
message: 'Media is unavailable',
});
});
test('Passes through other errors with error.response defined but no status', async () => {
const error = { response: {} } as any;
mockedAxios.get.mockRejectedValueOnce(error);
const errorCb = jest.fn();
await getRequest(url, true, undefined, errorCb);
expect(errorCb).toHaveBeenCalledWith(error);
});
// @todo: Revisit this behavior
test('When no errorCallback provided, it should not crash on error (async)', async () => {
mockedAxios.get.mockRejectedValueOnce(new Error('boom'));
await expect(getRequest(url, true, undefined as any, undefined as any)).resolves.toBeUndefined();
});
});
describe('postRequest', () => {
const url = '/api/post';
test('Calls axios.post with provided data and config (async mode)', () => {
mockedAxios.post.mockResolvedValueOnce({ data: 'ok' } as any);
const cb = jest.fn();
postRequest(url, { a: 1 }, { headers: { h: 'v' } }, false, cb, undefined);
expect(mockedAxios.post).toHaveBeenCalledWith(url, { a: 1 }, { headers: { h: 'v' } });
});
test('Defaults postData to {} when undefined', async () => {
mockedAxios.post.mockResolvedValueOnce({ data: 'ok' } as any);
const cb = jest.fn();
await postRequest(url, undefined as any, undefined as any, true, cb, undefined);
expect(mockedAxios.post).toHaveBeenCalledWith(url, {}, null);
expect(cb).toHaveBeenCalled();
});
test('Invokes errorCallback on error as-is', async () => {
const error = new Error('fail');
mockedAxios.post.mockRejectedValueOnce(error);
const errorCb = jest.fn();
await postRequest(url, {}, undefined, true, undefined, errorCb);
expect(errorCb).toHaveBeenCalledWith(error);
});
});
describe('putRequest', () => {
const url = '/api/put';
test('Calls axios.put with provided data and config', async () => {
mockedAxios.put.mockResolvedValueOnce({ data: 'ok' } as any);
const cb = jest.fn();
await putRequest(url, { a: 1 }, { headers: { h: 'v' } }, true, cb, undefined);
expect(mockedAxios.put).toHaveBeenCalledWith(url, { a: 1 }, { headers: { h: 'v' } });
expect(cb).toHaveBeenCalled();
});
test('Defaults putData to {} when undefined', async () => {
mockedAxios.put.mockResolvedValueOnce({ data: 'ok' } as any);
await putRequest(url, undefined as any, undefined as any, true, undefined, undefined);
expect(mockedAxios.put).toHaveBeenCalledWith(url, {}, null);
});
test('Invokes errorCallback on error', async () => {
const error = new Error('fail');
mockedAxios.put.mockRejectedValueOnce(error);
const errorCb = jest.fn();
await putRequest(url, {}, undefined, true, undefined, errorCb);
expect(errorCb).toHaveBeenCalledWith(error);
});
});
describe('deleteRequest', () => {
const url = '/api/delete';
test('Calls axios.delete with provided config', async () => {
mockedAxios.delete.mockResolvedValueOnce({ data: 'ok' } as any);
const cb = jest.fn();
await deleteRequest(url, { headers: { h: 'v' } }, true, cb, undefined);
expect(mockedAxios.delete).toHaveBeenCalledWith(url, { headers: { h: 'v' } });
expect(cb).toHaveBeenCalled();
});
test('Defaults configData to {} when undefined', async () => {
mockedAxios.delete.mockResolvedValueOnce({ data: 'ok' } as any);
await deleteRequest(url, undefined as any, true, undefined, undefined);
expect(mockedAxios.delete).toHaveBeenCalledWith(url, {});
});
test('Invokes errorCallback on error', async () => {
const error = new Error('fail');
mockedAxios.delete.mockRejectedValueOnce(error);
const errorCb = jest.fn();
await deleteRequest(url, {}, true, undefined, errorCb);
expect(errorCb).toHaveBeenCalledWith(error);
});
});
describe('sync vs async behavior', () => {
test('sync=true awaits the axios promise', async () => {
const thenable = Promise.resolve({ data: 'ok' } as any);
mockedAxios.post.mockReturnValueOnce(thenable as any);
const cb = jest.fn();
const p = postRequest('/api/p', {}, undefined, true, cb, undefined);
// When awaited, callback should be called before next tick
await p;
expect(cb).toHaveBeenCalled();
});
test('sync=false does not need awaiting; call still issued', () => {
mockedAxios.put.mockResolvedValueOnce({ data: 'ok' } as any);
putRequest('/api/p', {}, undefined, false, undefined, undefined);
expect(mockedAxios.put).toHaveBeenCalled();
});
});
});
});

View File

@ -0,0 +1,53 @@
import { translateString } from '../../../src/static/js/utils/helpers/translate';
declare global {
interface Window {
TRANSLATION?: Record<string, string>;
}
}
describe('js/utils/helpers', () => {
describe('translate', () => {
const originalReplacements = window.TRANSLATION;
beforeEach(() => {
delete window.TRANSLATION;
});
afterEach(() => {
window.TRANSLATION = originalReplacements;
});
test('Returns the same word when window.TRANSLATION is undefined', () => {
delete window.TRANSLATION;
expect(translateString('Hello')).toBe('Hello');
expect(translateString('NonExistingKey')).toBe('NonExistingKey');
expect(translateString('')).toBe('');
});
test('Returns mapped value when key exists in window.TRANSLATION', () => {
window.TRANSLATION = { Hello: 'Γεια', World: 'Κόσμος' };
expect(translateString('Hello')).toBe('Γεια');
expect(translateString('World')).toBe('Κόσμος');
});
test('Falls back to original word when key is missing in Twindow.RANSLATION', () => {
window.TRANSLATION = { Hello: 'Γεια' };
expect(translateString('MissingKey')).toBe('MissingKey');
expect(translateString('AnotherMissing')).toBe('AnotherMissing');
});
test('Supports empty string keys distinctly from missing keys', () => {
window.TRANSLATION = { '': '(empty)' };
expect(translateString('')).toBe('(empty)');
expect(translateString(' ')).toBe(' ');
});
test('Returns value as-is even if it is an empty string or falsy in the dictionary', () => {
window.TRANSLATION = { Empty: '', Zero: '0', False: 'false' };
expect(translateString('Empty')).toBe('');
expect(translateString('Zero')).toBe('0');
expect(translateString('False')).toBe('false');
});
});
});

View File

@ -0,0 +1,79 @@
import { init, endpoints } from '../../../src/static/js/utils/settings/api';
const apiConfig = (url: any, ep: any) => {
init(url, ep);
return endpoints();
};
describe('utils/settings', () => {
describe('api', () => {
const sampleGlobal = {
site: { api: 'https://example.com/api/v1///' },
// The endpoints below intentionally contain leading slashes to ensure they are stripped
api: {
media: '/media/',
members: '/users//',
playlists: '/playlists',
liked: '/user/liked',
history: '/user/history',
tags: '/tags',
categories: '/categories',
manage_media: '/manage/media',
manage_users: '/manage/users',
manage_comments: '/manage/comments',
search: '/search',
},
} as const;
test('Trims trailing slashes on base and ensures single slash joins', () => {
const cfg = apiConfig(sampleGlobal.site.api, sampleGlobal.api);
// @todo: Check again the cases of trailing slashes
expect(cfg.media).toBe('https://example.com/api/v1/media/');
expect(cfg.users).toBe('https://example.com/api/v1/users//');
});
test('Adds featured/recommended query to media variants', () => {
const cfg = apiConfig(sampleGlobal.site.api, sampleGlobal.api);
expect(cfg.featured).toBe('https://example.com/api/v1/media/?show=featured');
expect(cfg.recommended).toBe('https://example.com/api/v1/media/?show=recommended');
});
test('Builds nested user, archive, manage maps', () => {
const cfg = apiConfig(sampleGlobal.site.api, sampleGlobal.api);
expect(cfg.user.liked).toBe('https://example.com/api/v1/user/liked');
expect(cfg.user.history).toBe('https://example.com/api/v1/user/history');
expect(cfg.user.playlists).toBe('https://example.com/api/v1/playlists?author=');
expect(cfg.archive.tags).toBe('https://example.com/api/v1/tags');
expect(cfg.archive.categories).toBe('https://example.com/api/v1/categories');
expect(cfg.manage.media).toBe('https://example.com/api/v1/manage/media');
expect(cfg.manage.users).toBe('https://example.com/api/v1/manage/users');
expect(cfg.manage.comments).toBe('https://example.com/api/v1/manage/comments');
});
test('Builds search endpoints with expected query fragments', () => {
const cfg = apiConfig(sampleGlobal.site.api, sampleGlobal.api);
expect(cfg.search.query).toBe('https://example.com/api/v1/search?q=');
expect(cfg.search.titles).toBe('https://example.com/api/v1/search?show=titles&q=');
expect(cfg.search.tag).toBe('https://example.com/api/v1/search?t=');
expect(cfg.search.category).toBe('https://example.com/api/v1/search?c=');
});
test('Handles base url with path and endpoint with existing query', () => {
const cfg = apiConfig('https://example.com/base/', {
media: 'items?x=1',
playlists: '/pls/',
liked: 'me/liked',
categories: '/c',
search: '/s',
});
expect(cfg.media).toBe('https://example.com/base/items?x=1');
expect(cfg.playlists).toBe('https://example.com/base/pls/');
expect(cfg.user.liked).toBe('https://example.com/base/me/liked');
expect(cfg.archive.categories).toBe('https://example.com/base/c');
expect(cfg.search.query).toBe('https://example.com/base/s?q=');
});
});
});

View File

@ -0,0 +1,189 @@
import { config } from '../../../src/static/js/utils/settings/config';
describe('utils/settings', () => {
describe('config', () => {
const baseGlobal = {
profileId: 'john',
site: {
id: 'my-site',
url: 'https://example.com/',
api: 'https://example.com/api/',
title: 'Example',
theme: { mode: 'dark', switch: { enabled: true, position: 'sidebar' } },
logo: {
lightMode: { img: '/img/light.png', svg: '/img/light.svg' },
darkMode: { img: '/img/dark.png', svg: '/img/dark.svg' },
},
devEnv: false,
useRoundedCorners: true,
version: '2.0.0',
taxonomies: {
tags: { enabled: true, title: 'Topic Tags' },
categories: { enabled: false, title: 'Kinds' },
},
pages: {
latest: { enabled: true, title: 'Recent uploads' },
featured: { enabled: true, title: 'Featured picks' },
recommended: { enabled: false, title: 'You may like' },
},
userPages: {
members: { enabled: true, title: 'People' },
liked: { enabled: true, title: 'Favorites' },
history: { enabled: true, title: 'Watched' },
},
},
url: {
home: '/',
admin: '/admin',
error404: '/404',
latestMedia: '/latest',
featuredMedia: '/featured',
recommendedMedia: '/recommended',
signin: '/signin',
signout: '/signout',
register: '/register',
changePassword: '/password',
members: '/members',
search: '/search',
likedMedia: '/liked',
history: '/history',
addMedia: '/add',
editChannel: '/edit/channel',
editProfile: '/edit/profile',
tags: '/tags',
categories: '/categories',
manageMedia: '/manage/media',
manageUsers: '/manage/users',
manageComments: '/manage/comments',
},
api: {
media: 'v1/media/',
playlists: 'v1/playlists',
members: 'v1/users',
liked: 'v1/user/liked',
history: 'v1/user/history',
tags: 'v1/tags',
categories: 'v1/categories',
manage_media: 'v1/manage/media',
manage_users: 'v1/manage/users',
manage_comments: 'v1/manage/comments',
search: 'v1/search',
},
contents: {
notifications: {
messages: {
addToLiked: 'Yay',
removeFromLiked: 'Oops',
addToDisliked: 'nay',
removeFromDisliked: 'ok',
},
},
},
pages: {
home: { sections: { latest: { title: 'Latest T' } } },
search: { advancedFilters: true },
media: { categoriesWithTitle: true, hideViews: true, related: { initialSize: 5 } },
profile: { htmlInDescription: true, includeHistory: true, includeLikedMedia: true },
},
features: {
mediaItem: { hideAuthor: true, hideViews: false, hideDate: true },
media: {
actions: {
like: true,
dislike: true,
report: true,
comment: true,
comment_mention: true,
download: true,
save: true,
share: true,
},
shareOptions: ['embed', 'email', 'invalid'],
},
playlists: { mediaTypes: ['audio'] },
sideBar: { hideHomeLink: false, hideTagsLink: true, hideCategoriesLink: false },
embeddedVideo: { initialDimensions: { width: 640, height: 360 } },
headerBar: { hideLogin: false, hideRegister: true },
},
user: {
is: { anonymous: false, admin: true },
name: ' John ',
username: ' john ',
thumbnail: ' /img/j.png ',
can: {
changePassword: true,
deleteProfile: true,
addComment: true,
mentionComment: true,
deleteComment: true,
editMedia: true,
deleteMedia: true,
editSubtitle: true,
manageMedia: true,
manageUsers: true,
manageComments: true,
contactUser: true,
canSeeMembersPage: true,
usersNeedsToBeApproved: false,
addMedia: true,
editProfile: true,
readComment: true,
},
pages: { about: '/u/john/about ', media: '/u/john ', playlists: '/u/john/playlists ' },
},
} as const;
test('merges enabled pages and passes titles into options.pages.home sections', () => {
const cfg = config(baseGlobal);
expect(cfg.enabled.pages.latest).toStrictEqual({ enabled: true, title: 'Recent uploads' });
expect(cfg.enabled.pages.featured).toStrictEqual({ enabled: true, title: 'Featured picks' });
expect(cfg.enabled.pages.recommended).toStrictEqual({ enabled: false, title: 'You may like' });
expect(cfg.enabled.pages.members).toStrictEqual({ enabled: true, title: 'People' });
expect(cfg.options.pages.home.sections.latest.title).toBe('Latest T');
expect(cfg.options.pages.home.sections.featured.title).toBe('Featured picks');
});
test('produces api endpoints based on site.api and api endpoints', () => {
const cfg = config(baseGlobal);
expect(cfg.api.media).toBe('https://example.com/api/v1/media/');
expect(cfg.api.user.liked).toBe('https://example.com/api/v1/user/liked');
expect(cfg.api.search.query).toBe('https://example.com/api/v1/search?q=');
});
test('member and url manage links reflect user and feature flags', () => {
const cfg = config(baseGlobal);
expect(cfg.member.is).toStrictEqual({ admin: true, anonymous: false });
expect(cfg.member.can).toMatchObject({
manageMedia: true,
manageUsers: true,
manageComments: true,
likeMedia: true,
});
expect(cfg.url.manage.media).toBe('/manage/media');
expect(cfg.url.signout).toBe('/signout');
// admin visible
expect(cfg.url.admin).toBe('/admin');
});
test('theme and site defaults propagate correctly', () => {
const cfg = config(baseGlobal);
expect(cfg.theme.mode).toBe('dark');
expect(cfg.theme.switch.position).toBe('sidebar');
expect(cfg.theme.logo.darkMode.img).toBe('/img/dark.png');
expect(cfg.site.id).toBe('my-site');
expect(cfg.site.version).toBe('2.0.0');
});
test('memoizes and returns the same object instance on repeated calls', () => {
const first = config(baseGlobal);
const second = config(baseGlobal);
expect(second).toBe(first);
});
test('url profile paths use site.url when not in dev env', () => {
const cfg = config(baseGlobal);
expect(cfg.url.profile.media).toBe('https://example.com/user/john');
expect(cfg.url.embed).toBe('https://example.com/embed?m=');
});
});
});

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