Compare commits

...

43 Commits

Author SHA1 Message Date
Yiannis Christodoulou
283d242325 build assets 2026-02-03 23:01:48 +02:00
Yiannis Christodoulou
2bf622f8f1 build assets 2026-02-03 22:59:15 +02:00
Yiannis Christodoulou
0753a9e803 Remove vertical aspect ratios from embed options
The aspect ratio dropdown in MediaShareEmbed now only includes horizontal orientation options (16:9, 4:3, 3:2) and the custom option. Vertical orientation options have been removed to simplify the selection.
2026-02-03 22:59:14 +02:00
Yiannis Christodoulou
f293f584ec build assets 2026-02-03 22:59:14 +02:00
Yiannis Christodoulou
ecaa1ce37f feat: allow custom width/height in embed options without aspect ratio lock 2026-02-03 22:59:14 +02:00
Yiannis Christodoulou
ae2377f8d3 feat: remember user's embed video preferences in localStorage 2026-02-03 22:59:14 +02:00
Yiannis Christodoulou
59aaa6188b build assets 2026-02-03 22:59:14 +02:00
Yiannis Christodoulou
78214db44b Improve focus and scroll behavior for embedded video player
Prevent automatic focus on the video element when in embed mode to avoid unexpected behavior. Update scroll logic to only scroll the video into view if not in an iframe, or if explicitly requested via a URL parameter, improving user experience for embedded players.
2026-02-03 22:59:14 +02:00
Yiannis Christodoulou
6e40b388e0 update static files 2026-02-03 22:59:14 +02:00
Yiannis Christodoulou
6729c6df13 Add full-screen video embed example
Introduces an HTML example for embedding a full-screen video using an iframe, styled for centered display and responsive aspect ratio.
2026-02-03 22:59:14 +02:00
Yiannis Christodoulou
ff47daa7c9 Update MediaShareEmbed.jsx 2026-02-03 22:59:14 +02:00
Yiannis Christodoulou
2a0928aeaf Update default embed options in MediaShareEmbed
Changed initial states for keepAspectRatio, showTitle, and responsive options in MediaShareEmbed to improve default embed behavior. Also simplified aspect ratio change logic by removing unit options adjustments.
2026-02-03 22:59:14 +02:00
Yiannis Christodoulou
b8b6801dac Remove test iframe HTML files
Deleted __test-iframe/index.html and __test-iframe/index2.html as they are no longer needed for testing embedded video functionality.
2026-02-03 22:59:14 +02:00
Yiannis Christodoulou
9945e63a49 update static files 2026-02-03 22:59:14 +02:00
Yiannis Christodoulou
3d7a75a032 fix embed options 2026-02-03 22:57:20 +02:00
Yiannis Christodoulou
60d1137ac0 style: Improve embed options UI and logic
Refactored the embed options layout to use a grid for better alignment and spacing. The 'Link title' and 'Show user avatar' checkboxes are now disabled and visually dimmed when 'Show title' is unchecked, improving usability and clarity.
2026-02-03 22:53:20 +02:00
Yiannis Christodoulou
f5613c932c feat: add linkTitle options to embed player
- Added 'linkTitle' to toggle between clickable and
2026-02-03 22:53:20 +02:00
Yiannis Christodoulou
694d80445c feat: add showUserAvatar option to video player and embed UI
- Added 'showUserAvatar' parameter to control author avatar visibility in embed info overlay
- Implemented UI toggle in MediaShareEmbed for the 'showUserAvatar' option
- Propagated 'showUserAvatar' through EmbedPage, VideoViewer, and VideoJSEmbed
- Updated test iframe index with all 8 combinations of showTitle, showRelated, and showUserAvatar
2026-02-03 22:53:20 +02:00
Yiannis Christodoulou
946304b46c feat: add showRelated option to video player and embed UI
- Added 'showRelated' parameter to control related videos visibility at video end
- Implemented UI toggle in MediaShareEmbed for the 'showRelated' option
- Updated EndScreenHandler to honor the 'showRelated' setting
- Modified EmbedPage and VideoJSEmbed to pass the parameter from URL to player
2026-02-03 22:53:19 +02:00
Yiannis Christodoulou
16468e173c build assets 2026-02-03 22:53:19 +02:00
Yiannis Christodoulou
217c3f7ebc feat: add showTitle option for embed videos
- Add showTitle prop support to EmbedInfoOverlay to conditionally show/hide title overlay
- Add showTitle checkbox option in MediaShareEmbed dialog with URL parameter support
- Update embed code generation to include showTitle parameter
- Add copy-url and copy-embed visual feedback icons to SeekIndicator
- Support showTitle prop in VideoJSEmbed component
2026-02-03 22:53:19 +02:00
Yiannis Christodoulou
91157da537 feat: add right-click context menu with share/embed options on video player
- Add VideoContextMenu component with copy URL, URL with timestamp, and embed code options
- Integrate context menu into VideoJSPlayer with proper event handling
- Add visual feedback via SeekIndicator when copying URLs/embed code
- Support embed mode with showTitle URL parameter handling
- Handle cross-origin iframe scenarios for embed URL generation
- Add helper functions for media ID, origin, and embed URL resolution
2026-02-03 22:53:19 +02:00
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
Markos Gogoulos
9b3d9fe1e7 trim (#1431) 2025-11-13 12:42:48 +02:00
Markos Gogoulos
ea340b6a2e V7 f4 (#1430) 2025-11-13 12:30:25 +02:00
Markos Gogoulos
ba2c31b1e6 fix: static files (#1429) 2025-11-12 14:08:02 +02:00
Yiannis Christodoulou
5eb6fafb8c fix: Show default chapter names in textarea instead of placeholder text (#1428)
* Refactor chapter filtering and auto-save logic

Simplified chapter filtering to only exclude empty titles, allowing default chapter names. Updated auto-save logic to skip saving when there are no chapters or mediaId. Removed unused helper function and improved debug logging.

* Show default chapter title in editor and set initial title

The chapter title is now always displayed in the textarea, including default names like 'Chapter 1'. Also, the initial segment is created with 'Chapter 1' as its title instead of an empty string for better clarity.

* build assets
2025-11-12 14:04:07 +02:00
Markos Gogoulos
c035bcddf5 small 7.2.x fixes 2025-11-11 19:51:42 +02:00
Markos Gogoulos
01912ea1f9 fix: adjust poster url for audio 2025-11-11 13:21:10 +02:00
Markos Gogoulos
d9f299af4d V7 small fixes (#1426) 2025-11-11 13:15:36 +02:00
177 changed files with 13172 additions and 6239 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

@@ -1,3 +1,4 @@
/templates/cms/* /templates/cms/*
/templates/*.html /templates/*.html
*.scss *.scss
/frontend/

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.0" 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,34 +191,76 @@ 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 getattr(settings, 'USE_RBAC', False) and 'category' in self.fields: 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()
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True) rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
if rbac_categories or custom_permissions:
if rbac_categories and state in ['private', 'unlisted']:
# Make the confirm_state field visible and add it to the layout
self.fields['confirm_state'].widget = forms.CheckboxInput() self.fields['confirm_state'].widget = forms.CheckboxInput()
# add it after the state field
state_index = None state_index = None
for i, layout_item in enumerate(self.helper.layout): for i, layout_item in enumerate(self.helper.layout):
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state': if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
state_index = i state_index = i
break break
if state_index: 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)
if not cleaned_data.get('confirm_state'): if not cleaned_data.get('confirm_state'):
error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to the following categories: {', '.join(rbac_categories)}" if rbac_categories:
self.add_error('confirm_state', error_message) error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to categories: {', '.join(rbac_categories)}"
self.add_error('confirm_state', error_message)
if custom_permissions:
error_message = f"I understand that although media state is {state}, the media is also shared by me with other users, that I can see in the 'Shared by me' page"
self.add_error('confirm_state', error_message)
# Convert "shared" state to actual underlying state for saving. we dont keep shared state in DB
if state == "shared":
cleaned_data["state"] = self.actual_state
return cleaned_data return cleaned_data
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
data = self.cleaned_data data = self.cleaned_data
state = data.get("state") state = data.get("state")
# If transitioning from shared to private, remove all sharing
if self.is_shared and state == 'private' and data.get('confirm_state'):
# Remove all custom permissions
self.instance.permissions.all().delete()
# Remove RBAC categories
rbac_cats = self.instance.category.filter(is_rbac_category=True)
self.instance.category.remove(*rbac_cats)
if state != self.initial["state"]: if state != self.initial["state"]:
self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state) self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state)
@@ -332,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

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

View File

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

View File

@@ -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
@@ -282,7 +284,7 @@ class Media(models.Model):
self.allow_whisper_transcribe != self.__original_allow_whisper_transcribe or self.allow_whisper_transcribe_and_translate != self.__original_allow_whisper_transcribe_and_translate self.allow_whisper_transcribe != self.__original_allow_whisper_transcribe or self.allow_whisper_transcribe_and_translate != self.__original_allow_whisper_transcribe_and_translate
) )
if transcription_changed and self.media_type == "video": if transcription_changed and self.media_type in ["video", "audio"]:
self.transcribe_function() self.transcribe_function()
# Update the original values for next comparison # Update the original values for next comparison
@@ -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:
@@ -763,6 +777,8 @@ class Media(models.Model):
return helpers.url_from_path(self.uploaded_thumbnail.path) return helpers.url_from_path(self.uploaded_thumbnail.path)
if self.thumbnail: if self.thumbnail:
return helpers.url_from_path(self.thumbnail.path) return helpers.url_from_path(self.thumbnail.path)
if self.media_type == "audio":
return helpers.url_from_path("userlogos/poster_audio.jpg")
return None return None
@property @property
@@ -776,6 +792,9 @@ class Media(models.Model):
return helpers.url_from_path(self.uploaded_poster.path) return helpers.url_from_path(self.uploaded_poster.path)
if self.poster: if self.poster:
return helpers.url_from_path(self.poster.path) return helpers.url_from_path(self.poster.path)
if self.media_type == "audio":
return helpers.url_from_path("userlogos/poster_audio.jpg")
return None return None
@property @property

View File

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

@@ -26,18 +26,6 @@ const mediaPageLinkStyles = {
}, },
} as const; } as const;
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
const parseTimeToSeconds = (timeString: string): number => {
const parts = timeString.split(':');
if (parts.length !== 3) return 0;
const hours = parseInt(parts[0], 10) || 0;
const minutes = parseInt(parts[1], 10) || 0;
const seconds = parseFloat(parts[2]) || 0;
return hours * 3600 + minutes * 60 + seconds;
};
interface TimelineControlsProps { interface TimelineControlsProps {
currentTime: number; currentTime: number;
duration: number; duration: number;
@@ -189,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(() => {
@@ -203,17 +200,7 @@ const TimelineControls = ({
setIsAutoSaving(true); setIsAutoSaving(true);
// Format segments data for API request - use ref to get latest segments and sort by start time // Format segments data for API request - use ref to get latest segments and sort by start time
// ONLY save chapters that have custom titles - filter out chapters without titles or with default names
const chapters = clipSegmentsRef.current const chapters = clipSegmentsRef.current
.filter((segment) => {
// Filter out empty titles
if (!segment.chapterTitle || !segment.chapterTitle.trim()) {
return false;
}
// Filter out default chapter names like "Chapter 1", "Chapter 2", etc.
const isDefaultName = /^Chapter \d+$/.test(segment.chapterTitle);
return !isDefaultName;
})
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically .sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
.map((chapter) => ({ .map((chapter) => ({
startTime: formatDetailedTime(chapter.startTime), startTime: formatDetailedTime(chapter.startTime),
@@ -221,7 +208,7 @@ const TimelineControls = ({
chapterTitle: chapter.chapterTitle, chapterTitle: chapter.chapterTitle,
})); }));
logger.debug('Filtered chapters (only custom titles):', chapters); logger.debug('chapters', chapters);
const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null; const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
// For testing, use '1234' if no mediaId is available // For testing, use '1234' if no mediaId is available
@@ -229,13 +216,12 @@ const TimelineControls = ({
logger.debug('mediaId', finalMediaId); logger.debug('mediaId', finalMediaId);
if (!finalMediaId) { if (!finalMediaId || chapters.length === 0) {
logger.debug('No mediaId, skipping auto-save'); logger.debug('No mediaId or segments, skipping auto-save');
setIsAutoSaving(false); setIsAutoSaving(false);
return; return;
} }
// Save chapters (empty array if no chapters have titles)
logger.debug('Auto-saving segments:', { mediaId: finalMediaId, chapters }); logger.debug('Auto-saving segments:', { mediaId: finalMediaId, chapters });
const response = await autoSaveVideo(finalMediaId, { chapters }); const response = await autoSaveVideo(finalMediaId, { chapters });
@@ -291,13 +277,8 @@ const TimelineControls = ({
// Update editing title when selected segment changes // Update editing title when selected segment changes
useEffect(() => { useEffect(() => {
if (selectedSegment) { if (selectedSegment) {
// Check if the chapter title is a default generated name (e.g., "Chapter 1", "Chapter 2", etc.) // Always show the chapter title in the textarea, whether it's default or custom
const isDefaultChapterName = selectedSegment.chapterTitle && setEditingChapterTitle(selectedSegment.chapterTitle || '');
/^Chapter \d+$/.test(selectedSegment.chapterTitle);
// If it's a default name, show empty string so placeholder appears
// If it's a custom title, show the actual title
setEditingChapterTitle(isDefaultChapterName ? '' : (selectedSegment.chapterTitle || ''));
} else { } else {
setEditingChapterTitle(''); setEditingChapterTitle('');
} }
@@ -522,20 +503,11 @@ const TimelineControls = ({
try { try {
// Format chapters data for API request - sort by start time first // Format chapters data for API request - sort by start time first
// ONLY save chapters that have custom titles - filter out chapters without titles or with default names
const chapters = clipSegments const chapters = clipSegments
.filter((segment) => { .filter((segment) => segment.chapterTitle && segment.chapterTitle.trim())
// Filter out empty titles
if (!segment.chapterTitle || !segment.chapterTitle.trim()) {
return false;
}
// Filter out default chapter names like "Chapter 1", "Chapter 2", etc.
const isDefaultName = /^Chapter \d+$/.test(segment.chapterTitle);
return !isDefaultName;
})
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically .sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
.map((segment) => ({ .map((segment) => ({
chapterTitle: segment.chapterTitle, chapterTitle: segment.chapterTitle || `Chapter ${segment.id}`,
from: formatDetailedTime(segment.startTime), from: formatDetailedTime(segment.startTime),
to: formatDetailedTime(segment.endTime), to: formatDetailedTime(segment.endTime),
})); }));
@@ -904,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]);
@@ -1121,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);
@@ -1146,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);
}; };
@@ -1162,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;
@@ -1171,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);
@@ -1181,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
@@ -1225,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;
@@ -1232,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);
@@ -1253,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;
@@ -1273,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)
@@ -1295,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) {
@@ -1348,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
@@ -1371,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',
});
} }
} }
}; };
@@ -1535,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: {
@@ -1619,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(
@@ -1631,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;
@@ -3980,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'
@@ -4119,4 +4107,4 @@ const TimelineControls = ({
); );
}; };
export default TimelineControls; export default TimelineControls;

View File

@@ -20,7 +20,7 @@ const useVideoChapters = () => {
// Sort by start time to find chronological position // Sort by start time to find chronological position
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime); const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
// Find the index of our new segment // Find the index of our new segment
const chapterIndex = sortedSegments.findIndex(seg => seg.startTime === newSegmentStartTime); const chapterIndex = sortedSegments.findIndex((seg) => seg.startTime === newSegmentStartTime);
return `Chapter ${chapterIndex + 1}`; return `Chapter ${chapterIndex + 1}`;
}; };
@@ -28,12 +28,18 @@ const useVideoChapters = () => {
const renumberAllSegments = (segments: Segment[]): Segment[] => { const renumberAllSegments = (segments: Segment[]): Segment[] => {
// Sort segments by start time // Sort segments by start time
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
// Renumber each segment based on its chronological position // Renumber each segment based on its chronological position
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
@@ -54,6 +60,9 @@ const useVideoChapters = () => {
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
// Track if editor has been initialized to prevent re-initialization on Safari metadata events
const isInitializedRef = useRef<boolean>(false);
// Timeline state // Timeline state
const [trimStart, setTrimStart] = useState(0); const [trimStart, setTrimStart] = useState(0);
@@ -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
@@ -150,7 +161,7 @@ const useVideoChapters = () => {
// Create a default segment that spans the entire video on first load // Create a default segment that spans the entire video on first load
const initialSegment: Segment = { const initialSegment: Segment = {
id: 1, id: 1,
chapterTitle: '', chapterTitle: 'Chapter 1',
startTime: 0, startTime: 0,
endTime: video.duration, endTime: video.duration,
}; };
@@ -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);
@@ -261,21 +268,21 @@ const useVideoChapters = () => {
useEffect(() => { useEffect(() => {
if (isSafari() && videoRef.current) { if (isSafari() && videoRef.current) {
const video = videoRef.current; const video = videoRef.current;
const initializeSafariOnInteraction = () => { const initializeSafariOnInteraction = () => {
// Try to load video metadata by attempting to play and immediately pause // Try to load video metadata by attempting to play and immediately pause
const attemptInitialization = async () => { const attemptInitialization = async () => {
try { try {
logger.debug('Safari: Attempting auto-initialization on user interaction'); logger.debug('Safari: Attempting auto-initialization on user interaction');
// Briefly play to trigger metadata loading, then pause // Briefly play to trigger metadata loading, then pause
await video.play(); await video.play();
video.pause(); video.pause();
// Check if we now have duration and initialize if needed // Check if we now have duration and initialize if needed
if (video.duration > 0 && clipSegments.length === 0) { if (video.duration > 0 && clipSegments.length === 0) {
logger.debug('Safari: Successfully initialized metadata, creating default segment'); logger.debug('Safari: Successfully initialized metadata, creating default segment');
const defaultSegment: Segment = { const defaultSegment: Segment = {
id: 1, id: 1,
chapterTitle: '', chapterTitle: '',
@@ -286,14 +293,14 @@ const useVideoChapters = () => {
setDuration(video.duration); setDuration(video.duration);
setTrimEnd(video.duration); setTrimEnd(video.duration);
setClipSegments([defaultSegment]); setClipSegments([defaultSegment]);
const initialState: EditorState = { const initialState: EditorState = {
trimStart: 0, trimStart: 0,
trimEnd: video.duration, trimEnd: video.duration,
splitPoints: [], splitPoints: [],
clipSegments: [defaultSegment], clipSegments: [defaultSegment],
}; };
setHistory([initialState]); setHistory([initialState]);
setHistoryPosition(0); setHistoryPosition(0);
} }
@@ -315,7 +322,7 @@ const useVideoChapters = () => {
// Add listeners for various user interactions // Add listeners for various user interactions
document.addEventListener('click', handleUserInteraction); document.addEventListener('click', handleUserInteraction);
document.addEventListener('keydown', handleUserInteraction); document.addEventListener('keydown', handleUserInteraction);
return () => { return () => {
document.removeEventListener('click', handleUserInteraction); document.removeEventListener('click', handleUserInteraction);
document.removeEventListener('keydown', handleUserInteraction); document.removeEventListener('keydown', handleUserInteraction);
@@ -332,7 +339,7 @@ const useVideoChapters = () => {
// This play/pause will trigger metadata loading in Safari // This play/pause will trigger metadata loading in Safari
await video.play(); await video.play();
video.pause(); video.pause();
// The metadata events should fire now and initialize segments // The metadata events should fire now and initialize segments
return true; return true;
} catch (error) { } catch (error) {
@@ -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 = {
@@ -919,10 +929,10 @@ const useVideoChapters = () => {
const singleChapter = backendChapters[0]; const singleChapter = backendChapters[0];
const startSeconds = parseTimeToSeconds(singleChapter.startTime); const startSeconds = parseTimeToSeconds(singleChapter.startTime);
const endSeconds = parseTimeToSeconds(singleChapter.endTime); const endSeconds = parseTimeToSeconds(singleChapter.endTime);
// Check if this single chapter spans the entire video (within 0.1 second tolerance) // Check if this single chapter spans the entire video (within 0.1 second tolerance)
const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1; const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1;
if (isFullVideoChapter) { if (isFullVideoChapter) {
logger.debug('Manual save: Single chapter spans full video - sending empty array'); logger.debug('Manual save: Single chapter spans full video - sending empty array');
backendChapters = []; backendChapters = [];

View File

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

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en" style="height: 100%">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embedded Video - Full Screen</title>
<style>
body {
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #000;
overflow: hidden;
}
</style>
</head>
<body>
<iframe
src="https://demo.mediacms.io/embed?m=zK2nirNLC"
style="
width: 100%;
max-width: calc(100vh * 16 / 9);
aspect-ratio: 16 / 9;
display: block;
margin: auto;
border: 0;
"
allowfullscreen
></iframe>
</body>
</html>

View File

@@ -204,6 +204,54 @@ class SeekIndicator extends Component {
</div> </div>
`; `;
textEl.textContent = 'Pause'; textEl.textContent = 'Pause';
} else if (direction === 'copy-url') {
iconEl.innerHTML = `
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
<div style="
width: ${circleSize};
height: ${circleSize};
border-radius: 50%;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
box-sizing: border-box;
overflow: hidden;
">
<svg viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</div>
</div>
`;
textEl.textContent = '';
} else if (direction === 'copy-embed') {
iconEl.innerHTML = `
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
<div style="
width: ${circleSize};
height: ${circleSize};
border-radius: 50%;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
box-sizing: border-box;
overflow: hidden;
">
<svg viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
<path d="M16 18l6-6-6-6"/>
<path d="M8 6l-6 6 6 6"/>
</svg>
</div>
</div>
`;
textEl.textContent = '';
} }
// Clear any text content in the text element // Clear any text content in the text element
@@ -239,6 +287,11 @@ class SeekIndicator extends Component {
this.showTimeout = setTimeout(() => { this.showTimeout = setTimeout(() => {
this.hide(); this.hide();
}, 500); }, 500);
} else if (direction === 'copy-url' || direction === 'copy-embed') {
// Copy operations: 500ms (same as play/pause)
this.showTimeout = setTimeout(() => {
this.hide();
}, 500);
} }
} }

View File

@@ -14,10 +14,22 @@ class EmbedInfoOverlay extends Component {
this.authorThumbnail = options.authorThumbnail || ''; this.authorThumbnail = options.authorThumbnail || '';
this.videoTitle = options.videoTitle || 'Video'; this.videoTitle = options.videoTitle || 'Video';
this.videoUrl = options.videoUrl || ''; this.videoUrl = options.videoUrl || '';
this.showTitle = options.showTitle !== undefined ? options.showTitle : true;
this.showRelated = options.showRelated !== undefined ? options.showRelated : true;
this.showUserAvatar = options.showUserAvatar !== undefined ? options.showUserAvatar : true;
this.linkTitle = options.linkTitle !== undefined ? options.linkTitle : true;
// Initialize after player is ready // Initialize after player is ready
this.player().ready(() => { this.player().ready(() => {
this.createOverlay(); if (this.showTitle) {
this.createOverlay();
} else {
// Hide overlay element if showTitle is false
const overlay = this.el();
overlay.style.display = 'none';
overlay.style.opacity = '0';
overlay.style.visibility = 'hidden';
}
}); });
} }
@@ -49,7 +61,7 @@ class EmbedInfoOverlay extends Component {
`; `;
// Create avatar container // Create avatar container
if (this.authorThumbnail) { if (this.authorThumbnail && this.showUserAvatar) {
const avatarContainer = document.createElement('div'); const avatarContainer = document.createElement('div');
avatarContainer.className = 'embed-avatar-container'; avatarContainer.className = 'embed-avatar-container';
avatarContainer.style.cssText = ` avatarContainer.style.cssText = `
@@ -125,7 +137,7 @@ class EmbedInfoOverlay extends Component {
overflow: hidden; overflow: hidden;
`; `;
if (this.videoUrl) { if (this.videoUrl && this.linkTitle) {
const titleLink = document.createElement('a'); const titleLink = document.createElement('a');
titleLink.href = this.videoUrl; titleLink.href = this.videoUrl;
titleLink.target = '_blank'; titleLink.target = '_blank';
@@ -186,10 +198,16 @@ class EmbedInfoOverlay extends Component {
const player = this.player(); const player = this.player();
const overlay = this.el(); const overlay = this.el();
// If showTitle is false, ensure overlay is hidden
if (!this.showTitle) {
overlay.style.display = 'none';
overlay.style.opacity = '0';
overlay.style.visibility = 'hidden';
return;
}
// Sync overlay visibility with control bar visibility // Sync overlay visibility with control bar visibility
const updateOverlayVisibility = () => { const updateOverlayVisibility = () => {
const controlBar = player.getChild('controlBar');
if (!player.hasStarted()) { if (!player.hasStarted()) {
// Show overlay when video hasn't started (poster is showing) - like before // Show overlay when video hasn't started (poster is showing) - like before
overlay.style.opacity = '1'; overlay.style.opacity = '1';

View File

@@ -0,0 +1,47 @@
.video-context-menu {
position: fixed;
background-color: #282828;
border-radius: 4px;
padding: 4px 0;
min-width: 240px;
z-index: 10000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.video-context-menu-item {
display: flex;
align-items: center;
padding: 10px 16px;
color: #ffffff;
cursor: pointer;
transition: background-color 0.15s ease;
font-size: 14px;
user-select: none;
}
.video-context-menu-item:hover {
background-color: #3d3d3d;
}
.video-context-menu-item:active {
background-color: #4a4a4a;
}
.video-context-menu-icon {
width: 18px;
height: 18px;
margin-right: 12px;
flex-shrink: 0;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.video-context-menu-item span {
flex: 1;
white-space: nowrap;
}

View File

@@ -0,0 +1,85 @@
import React, { useEffect, useRef } from 'react';
import './VideoContextMenu.css';
function VideoContextMenu({ visible, position, onClose, onCopyVideoUrl, onCopyVideoUrlAtTime, onCopyEmbedCode }) {
const menuRef = useRef(null);
useEffect(() => {
if (visible && menuRef.current) {
// Position the menu
menuRef.current.style.left = `${position.x}px`;
menuRef.current.style.top = `${position.y}px`;
// Adjust if menu goes off screen
const rect = menuRef.current.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
if (rect.right > windowWidth) {
menuRef.current.style.left = `${position.x - rect.width}px`;
}
if (rect.bottom > windowHeight) {
menuRef.current.style.top = `${position.y - rect.height}px`;
}
}
}, [visible, position]);
useEffect(() => {
const handleClickOutside = (e) => {
if (visible && menuRef.current && !menuRef.current.contains(e.target)) {
onClose();
}
};
const handleEscape = (e) => {
if (e.key === 'Escape' && visible) {
onClose();
}
};
if (visible) {
// Use capture phase to catch events earlier, before they can be stopped
// Listen to both mousedown and click to ensure we catch all clicks
document.addEventListener('mousedown', handleClickOutside, true);
document.addEventListener('click', handleClickOutside, true);
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside, true);
document.removeEventListener('click', handleClickOutside, true);
document.removeEventListener('keydown', handleEscape);
};
}, [visible, onClose]);
if (!visible) return null;
return (
<div ref={menuRef} className="video-context-menu" onClick={(e) => e.stopPropagation()}>
<div className="video-context-menu-item" onClick={onCopyVideoUrl}>
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>Copy video URL</span>
</div>
<div className="video-context-menu-item" onClick={onCopyVideoUrlAtTime}>
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>Copy video URL at current time</span>
</div>
<div className="video-context-menu-item" onClick={onCopyEmbedCode}>
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 18l6-6-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M8 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>Copy embed code</span>
</div>
</div>
);
}
export default VideoContextMenu;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useMemo } from 'react'; import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react';
import videojs from 'video.js'; import videojs from 'video.js';
import 'video.js/dist/video-js.css'; import 'video.js/dist/video-js.css';
import '../../styles/embed.css'; import '../../styles/embed.css';
@@ -17,6 +17,7 @@ import CustomRemainingTime from '../controls/CustomRemainingTime';
import CustomChaptersOverlay from '../controls/CustomChaptersOverlay'; import CustomChaptersOverlay from '../controls/CustomChaptersOverlay';
import CustomSettingsMenu from '../controls/CustomSettingsMenu'; import CustomSettingsMenu from '../controls/CustomSettingsMenu';
import SeekIndicator from '../controls/SeekIndicator'; import SeekIndicator from '../controls/SeekIndicator';
import VideoContextMenu from '../overlays/VideoContextMenu';
import UserPreferences from '../../utils/UserPreferences'; import UserPreferences from '../../utils/UserPreferences';
import PlayerConfig from '../../config/playerConfig'; import PlayerConfig from '../../config/playerConfig';
import { AutoplayHandler } from '../../utils/AutoplayHandler'; import { AutoplayHandler } from '../../utils/AutoplayHandler';
@@ -169,7 +170,7 @@ const enableStandardButtonTooltips = (player) => {
}, 500); // Delay to ensure all components are ready }, 500); // Delay to ensure all components are ready
}; };
function VideoJSPlayer({ videoId = 'default-video' }) { function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelated = true, showUserAvatar = true, linkTitle = true, urlTimestamp = null }) {
const videoRef = useRef(null); const videoRef = useRef(null);
const playerRef = useRef(null); // Track the player instance const playerRef = useRef(null); // Track the player instance
const userPreferences = useRef(new UserPreferences()); // User preferences instance const userPreferences = useRef(new UserPreferences()); // User preferences instance
@@ -177,25 +178,17 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
const keyboardHandler = useRef(null); // Keyboard handler instance const keyboardHandler = useRef(null); // Keyboard handler instance
const playbackEventHandler = useRef(null); // Playback event handler instance const playbackEventHandler = useRef(null); // Playback event handler instance
// Context menu state
const [contextMenuVisible, setContextMenuVisible] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
// Check if this is an embed player (disable next video and autoplay features) // Check if this is an embed player (disable next video and autoplay features)
const isEmbedPlayer = videoId === 'video-embed'; const isEmbedPlayer = videoId === 'video-embed';
// Utility function to detect touch devices
const isTouchDevice = useMemo(() => {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
}, []);
// Utility function to detect iOS devices
const isIOS = useMemo(() => {
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
);
}, []);
// Environment-based development mode configuration // Environment-based development mode configuration
const isDevMode = import.meta.env.VITE_DEV_MODE === 'true' || window.location.hostname.includes('vercel.app'); const isDevMode = import.meta.env.VITE_DEV_MODE === 'true' || window.location.hostname.includes('vercel.app');
// Safely access window.MEDIA_DATA with fallback using useMemo
// Read options from window.MEDIA_DATA if available (for consistency with embed logic)
const mediaData = useMemo( const mediaData = useMemo(
() => () =>
typeof window !== 'undefined' && window.MEDIA_DATA typeof window !== 'undefined' && window.MEDIA_DATA
@@ -214,12 +207,37 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
}, },
siteUrl: 'https://deic.mediacms.io', siteUrl: 'https://deic.mediacms.io',
nextLink: 'https://deic.mediacms.io/view?m=elygiagorgechania', nextLink: 'https://deic.mediacms.io/view?m=elygiagorgechania',
urlAutoplay: true,
urlMuted: false,
}, },
[] []
); );
// Helper to get effective value (prop or MEDIA_DATA or default)
const getOption = (propKey, mediaDataKey, defaultValue) => {
if (isEmbedPlayer) {
if (mediaData[mediaDataKey] !== undefined) return mediaData[mediaDataKey];
}
return propKey !== undefined ? propKey : defaultValue;
};
const finalShowTitle = getOption(showTitle, 'showTitle', true);
const finalShowRelated = getOption(showRelated, 'showRelated', true);
const finalShowUserAvatar = getOption(showUserAvatar, 'showUserAvatar', true);
const finalLinkTitle = getOption(linkTitle, 'linkTitle', true);
const finalTimestamp = getOption(urlTimestamp, 'urlTimestamp', null);
// Utility function to detect touch devices
const isTouchDevice = useMemo(() => {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
}, []);
// Utility function to detect iOS devices
const isIOS = useMemo(() => {
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
);
}, []);
// Define chapters as JSON object // Define chapters as JSON object
// Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON // Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON
// CONDITIONAL LOGIC: // CONDITIONAL LOGIC:
@@ -531,8 +549,6 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
isPlayList: mediaData?.isPlayList, isPlayList: mediaData?.isPlayList,
related_media: mediaData.data?.related_media || [], related_media: mediaData.data?.related_media || [],
nextLink: mediaData?.nextLink || null, nextLink: mediaData?.nextLink || null,
urlAutoplay: mediaData?.urlAutoplay || true,
urlMuted: mediaData?.urlMuted || false,
sources: getVideoSources(), sources: getVideoSources(),
}; };
@@ -738,6 +754,212 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
} }
}; };
// Context menu handlers
const handleContextMenu = useCallback((e) => {
// Only handle if clicking on video player area
const target = e.target;
const isVideoPlayerArea =
target.closest('.video-js') ||
target.classList.contains('vjs-tech') ||
target.tagName === 'VIDEO' ||
target.closest('video');
if (isVideoPlayerArea) {
e.preventDefault();
e.stopPropagation();
setContextMenuPosition({ x: e.clientX, y: e.clientY });
setContextMenuVisible(true);
}
}, []);
const closeContextMenu = () => {
setContextMenuVisible(false);
};
// Helper function to get media ID
const getMediaId = () => {
if (typeof window !== 'undefined' && window.MEDIA_DATA?.data?.friendly_token) {
return window.MEDIA_DATA.data.friendly_token;
}
if (mediaData?.data?.friendly_token) {
return mediaData.data.friendly_token;
}
// Try to get from URL (works for both main page and embed page)
if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
const mediaIdFromUrl = urlParams.get('m');
if (mediaIdFromUrl) {
return mediaIdFromUrl;
}
// Also check if we're on an embed page with media ID in path
const pathMatch = window.location.pathname.match(/\/embed\/([^/?]+)/);
if (pathMatch) {
return pathMatch[1];
}
}
return currentVideo.id || 'default-video';
};
// Helper function to get base origin URL (handles embed mode)
const getBaseOrigin = () => {
if (typeof window !== 'undefined') {
// In embed mode, try to get origin from parent window if possible
// Otherwise use current window origin
try {
// Check if we're in an iframe and can access parent
if (window.parent !== window && window.parent.location.origin) {
return window.parent.location.origin;
}
} catch {
// Cross-origin iframe, use current origin
}
return window.location.origin;
}
return mediaData.siteUrl || 'https://deic.mediacms.io';
};
// Helper function to get embed URL
const getEmbedUrl = () => {
const mediaId = getMediaId();
const origin = getBaseOrigin();
// Try to get embed URL from config or construct it
if (typeof window !== 'undefined' && window.MediaCMS?.config?.url?.embed) {
return window.MediaCMS.config.url.embed + mediaId;
}
// Fallback: construct embed URL (check if current URL is embed format)
if (typeof window !== 'undefined' && window.location.pathname.includes('/embed')) {
// If we're already on an embed page, use current URL format
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set('m', mediaId);
return currentUrl.toString();
}
// Default embed URL format
return `${origin}/embed?m=${mediaId}`;
};
// Copy video URL to clipboard
const handleCopyVideoUrl = async () => {
const mediaId = getMediaId();
const origin = getBaseOrigin();
const videoUrl = `${origin}/view?m=${mediaId}`;
// Show copy icon
if (customComponents.current?.seekIndicator) {
customComponents.current.seekIndicator.show('copy-url');
}
try {
await navigator.clipboard.writeText(videoUrl);
closeContextMenu();
// You can add a notification here if needed
} catch (err) {
console.error('Failed to copy video URL:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = videoUrl;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
closeContextMenu();
}
};
// Copy video URL at current time to clipboard
const handleCopyVideoUrlAtTime = async () => {
if (!playerRef.current) {
closeContextMenu();
return;
}
const currentTime = Math.floor(playerRef.current.currentTime() || 0);
const mediaId = getMediaId();
const origin = getBaseOrigin();
const videoUrl = `${origin}/view?m=${mediaId}&t=${currentTime}`;
// Show copy icon
if (customComponents.current?.seekIndicator) {
customComponents.current.seekIndicator.show('copy-url');
}
try {
await navigator.clipboard.writeText(videoUrl);
closeContextMenu();
} catch (err) {
console.error('Failed to copy video URL at time:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = videoUrl;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
closeContextMenu();
}
};
// Copy embed code to clipboard
const handleCopyEmbedCode = async () => {
const embedUrl = getEmbedUrl();
const embedCode = `<iframe width="560" height="315" src="${embedUrl}" frameborder="0" allowfullscreen></iframe>`;
// Show copy embed icon
if (customComponents.current?.seekIndicator) {
customComponents.current.seekIndicator.show('copy-embed');
}
try {
await navigator.clipboard.writeText(embedCode);
closeContextMenu();
} catch (err) {
console.error('Failed to copy embed code:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = embedCode;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
closeContextMenu();
}
};
// Add context menu handler directly to video element and document (works before and after Video.js initialization)
useEffect(() => {
const videoElement = videoRef.current;
// Attach to document with capture to catch all contextmenu events, then filter
const documentHandler = (e) => {
// Check if the event originated from within the video player
const target = e.target;
const playerWrapper =
videoElement?.closest('.video-js') || document.querySelector(`#${videoId}`)?.closest('.video-js');
if (playerWrapper && (playerWrapper.contains(target) || target === playerWrapper)) {
handleContextMenu(e);
}
};
// Use capture phase on document to catch before anything else
document.addEventListener('contextmenu', documentHandler, true);
// Also attach directly to video element
if (videoElement) {
videoElement.addEventListener('contextmenu', handleContextMenu, true);
}
return () => {
document.removeEventListener('contextmenu', documentHandler, true);
if (videoElement) {
videoElement.removeEventListener('contextmenu', handleContextMenu, true);
}
};
}, [handleContextMenu, videoId]);
useEffect(() => { useEffect(() => {
// Only initialize if we don't already have a player and element exists // Only initialize if we don't already have a player and element exists
if (videoRef.current && !playerRef.current) { if (videoRef.current && !playerRef.current) {
@@ -1078,6 +1300,9 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
currentVideo, currentVideo,
relatedVideos, relatedVideos,
goToNextVideo, goToNextVideo,
showRelated: finalShowRelated,
showUserAvatar: finalShowUserAvatar,
linkTitle: finalLinkTitle,
}); });
customComponents.current.endScreenHandler = endScreenHandler; // Store for cleanup customComponents.current.endScreenHandler = endScreenHandler; // Store for cleanup
@@ -1098,8 +1323,8 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
} }
// Handle URL timestamp parameter // Handle URL timestamp parameter
if (mediaData.urlTimestamp !== null && mediaData.urlTimestamp >= 0) { if (finalTimestamp !== null && finalTimestamp >= 0) {
const timestamp = mediaData.urlTimestamp; const timestamp = finalTimestamp;
// Wait for video metadata to be loaded before seeking // Wait for video metadata to be loaded before seeking
if (playerRef.current.readyState() >= 1) { if (playerRef.current.readyState() >= 1) {
@@ -1997,6 +2222,10 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
authorThumbnail: currentVideo.author_thumbnail, authorThumbnail: currentVideo.author_thumbnail,
videoTitle: currentVideo.title, videoTitle: currentVideo.title,
videoUrl: currentVideo.url, videoUrl: currentVideo.url,
showTitle: finalShowTitle,
showRelated: finalShowRelated,
showUserAvatar: finalShowUserAvatar,
linkTitle: finalLinkTitle,
}); });
} }
// END: Add Embed Info Overlay Component // END: Add Embed Info Overlay Component
@@ -2083,52 +2312,113 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
// Make the video element focusable // Make the video element focusable
const videoElement = playerRef.current.el(); const videoElement = playerRef.current.el();
videoElement.setAttribute('tabindex', '0'); videoElement.setAttribute('tabindex', '0');
videoElement.focus();
if (!isEmbedPlayer) {
videoElement.focus();
}
// Add context menu (right-click) handler to the player wrapper and video element
// Attach to player wrapper (this catches all clicks on the player)
videoElement.addEventListener('contextmenu', handleContextMenu, true);
// Also try to attach to the actual video tech element
const attachContextMenu = () => {
const techElement =
playerRef.current.el().querySelector('.vjs-tech') ||
playerRef.current.el().querySelector('video') ||
(playerRef.current.tech() && playerRef.current.tech().el());
if (techElement && techElement !== videoRef.current && techElement !== videoElement) {
// Use capture phase to catch before Video.js might prevent it
techElement.addEventListener('contextmenu', handleContextMenu, true);
return true;
}
return false;
};
// Try to attach immediately
attachContextMenu();
// Also try after a short delay in case elements aren't ready yet
setTimeout(() => {
attachContextMenu();
}, 100);
// Also try when video is loaded
playerRef.current.one('loadedmetadata', () => {
attachContextMenu();
});
} }
}); });
} }
//}, 0); //}, 0);
} }
// Cleanup: Remove context menu event listener
return () => {
if (playerRef.current && playerRef.current.el()) {
const playerEl = playerRef.current.el();
playerEl.removeEventListener('contextmenu', handleContextMenu, true);
const techElement =
playerEl.querySelector('.vjs-tech') ||
playerEl.querySelector('video') ||
(playerRef.current.tech() && playerRef.current.tech().el());
if (techElement) {
techElement.removeEventListener('contextmenu', handleContextMenu, true);
}
}
};
}, []); }, []);
return ( return (
<video <>
ref={videoRef} <video
id={videoId} ref={videoRef}
controls={true} id={videoId}
className={`video-js vjs-fluid vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`} controls={true}
preload="auto" className={`video-js ${isEmbedPlayer ? 'vjs-fill' : 'vjs-fluid'} vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`}
poster={currentVideo.poster} preload="auto"
tabIndex="0" poster={currentVideo.poster}
> tabIndex="0"
{/* <source src="/videos/sample-video.mp4" type="video/mp4" /> >
<source src="/videos/sample-video.webm" type="video/webm" /> */} {/* <source src="/videos/sample-video.mp4" type="video/mp4" />
<p className="vjs-no-js"> <source src="/videos/sample-video.webm" type="video/webm" /> */}
To view this video please enable JavaScript, and consider upgrading to a web browser that <p className="vjs-no-js">
<a href="https://videojs.com/html5-video-support/" target="_blank"> To view this video please enable JavaScript, and consider upgrading to a web browser that
supports HTML5 video <a href="https://videojs.com/html5-video-support/" target="_blank">
</a> supports HTML5 video
</p> </a>
</p>
{/* Add subtitle tracks */} {/* Add subtitle tracks */}
{/* {subtitleTracks && {/* {subtitleTracks &&
subtitleTracks.map((track, index) => ( subtitleTracks.map((track, index) => (
<track <track
key={index} key={index}
kind={track.kind} kind={track.kind}
src={track.src} src={track.src}
srcLang={track.srclang} srcLang={track.srclang}
label={track.label} label={track.label}
default={track.default} default={track.default}
/> />
))} */} ))} */}
{/* {/*
<track kind="chapters" src="/sample-chapters.vtt" /> */} <track kind="chapters" src="/sample-chapters.vtt" /> */}
{/* Add chapters track */} {/* Add chapters track */}
{/* {chaptersData && {/* {chaptersData &&
chaptersData.length > 0 && chaptersData.length > 0 &&
(console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */} (console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */}
</video> </video>
<VideoContextMenu
visible={contextMenuVisible}
position={contextMenuPosition}
onClose={closeContextMenu}
onCopyVideoUrl={handleCopyVideoUrl}
onCopyVideoUrlAtTime={handleCopyVideoUrlAtTime}
onCopyEmbedCode={handleCopyEmbedCode}
/>
</>
); );
} }

View File

@@ -63,7 +63,17 @@ export class EndScreenHandler {
} }
handleVideoEnded() { handleVideoEnded() {
const { isEmbedPlayer, userPreferences, mediaData, currentVideo, relatedVideos, goToNextVideo } = this.options; const {
isEmbedPlayer,
userPreferences,
mediaData,
currentVideo,
relatedVideos,
goToNextVideo,
showRelated,
showUserAvatar,
linkTitle,
} = this.options;
// For embed players, show big play button when video ends // For embed players, show big play button when video ends
if (isEmbedPlayer) { if (isEmbedPlayer) {
@@ -73,6 +83,34 @@ export class EndScreenHandler {
} }
} }
// If showRelated is false, we don't show the end screen or autoplay countdown
if (showRelated === false) {
// But we still want to keep the control bar visible and hide the poster
setTimeout(() => {
if (this.player && !this.player.isDisposed()) {
const playerEl = this.player.el();
if (playerEl) {
// Hide poster elements
const posterElements = playerEl.querySelectorAll('.vjs-poster');
posterElements.forEach((posterEl) => {
posterEl.style.display = 'none';
posterEl.style.visibility = 'hidden';
posterEl.style.opacity = '0';
});
// Keep control bar visible
const controlBar = this.player.getChild('controlBar');
if (controlBar) {
controlBar.show();
controlBar.el().style.opacity = '1';
controlBar.el().style.pointerEvents = 'auto';
}
}
}
}, 50);
return;
}
// Keep controls active after video ends // Keep controls active after video ends
setTimeout(() => { setTimeout(() => {
if (this.player && !this.player.isDisposed()) { if (this.player && !this.player.isDisposed()) {

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

@@ -31,8 +31,11 @@ const VideoJSEmbed = ({
poster, poster,
previewSprite, previewSprite,
subtitlesInfo, subtitlesInfo,
enableAutoplay,
inEmbed, inEmbed,
showTitle,
showRelated,
showUserAvatar,
linkTitle,
hasTheaterMode, hasTheaterMode,
hasNextLink, hasNextLink,
nextLink, nextLink,
@@ -62,8 +65,10 @@ const VideoJSEmbed = ({
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// Get URL parameters for autoplay, muted, and timestamp // Get URL parameters for autoplay, muted, and timestamp
const urlTimestamp = getUrlParameter('t'); const urlTimestamp = getUrlParameter('t');
const urlAutoplay = getUrlParameter('autoplay');
const urlMuted = getUrlParameter('muted'); const urlMuted = getUrlParameter('muted');
const urlShowRelated = getUrlParameter('showRelated');
const urlShowUserAvatar = getUrlParameter('showUserAvatar');
const urlLinkTitle = getUrlParameter('linkTitle');
window.MEDIA_DATA = { window.MEDIA_DATA = {
data: data || {}, data: data || {},
@@ -71,7 +76,7 @@ const VideoJSEmbed = ({
version: version, version: version,
isPlayList: isPlayList, isPlayList: isPlayList,
playerVolume: playerVolume || 0.5, playerVolume: playerVolume || 0.5,
playerSoundMuted: playerSoundMuted || (urlMuted === '1'), playerSoundMuted: urlMuted === '1',
videoQuality: videoQuality || 'auto', videoQuality: videoQuality || 'auto',
videoPlaybackSpeed: videoPlaybackSpeed || 1, videoPlaybackSpeed: videoPlaybackSpeed || 1,
inTheaterMode: inTheaterMode || false, inTheaterMode: inTheaterMode || false,
@@ -83,8 +88,11 @@ const VideoJSEmbed = ({
poster: poster || '', poster: poster || '',
previewSprite: previewSprite || null, previewSprite: previewSprite || null,
subtitlesInfo: subtitlesInfo || [], subtitlesInfo: subtitlesInfo || [],
enableAutoplay: enableAutoplay || (urlAutoplay === '1'),
inEmbed: inEmbed || false, inEmbed: inEmbed || false,
showTitle: showTitle || false,
showRelated: showRelated !== undefined ? showRelated : (urlShowRelated === '1' || urlShowRelated === 'true' || urlShowRelated === null),
showUserAvatar: showUserAvatar !== undefined ? showUserAvatar : (urlShowUserAvatar === '1' || urlShowUserAvatar === 'true' || urlShowUserAvatar === null),
linkTitle: linkTitle !== undefined ? linkTitle : (urlLinkTitle === '1' || urlLinkTitle === 'true' || urlLinkTitle === null),
hasTheaterMode: hasTheaterMode || false, hasTheaterMode: hasTheaterMode || false,
hasNextLink: hasNextLink || false, hasNextLink: hasNextLink || false,
nextLink: nextLink || null, nextLink: nextLink || null,
@@ -92,8 +100,10 @@ const VideoJSEmbed = ({
errorMessage: errorMessage || '', errorMessage: errorMessage || '',
// URL parameters // URL parameters
urlTimestamp: urlTimestamp ? parseInt(urlTimestamp, 10) : null, urlTimestamp: urlTimestamp ? parseInt(urlTimestamp, 10) : null,
urlAutoplay: urlAutoplay === '1',
urlMuted: urlMuted === '1', urlMuted: urlMuted === '1',
urlShowRelated: urlShowRelated === '1' || urlShowRelated === 'true',
urlShowUserAvatar: urlShowUserAvatar === '1' || urlShowUserAvatar === 'true',
urlLinkTitle: urlLinkTitle === '1' || urlLinkTitle === 'true',
onClickNextCallback: onClickNextCallback || null, onClickNextCallback: onClickNextCallback || null,
onClickPreviousCallback: onClickPreviousCallback || null, onClickPreviousCallback: onClickPreviousCallback || null,
onStateUpdateCallback: onStateUpdateCallback || null, onStateUpdateCallback: onStateUpdateCallback || null,
@@ -176,11 +186,17 @@ const VideoJSEmbed = ({
// Scroll to the video player with smooth behavior // Scroll to the video player with smooth behavior
const videoElement = document.querySelector(inEmbedRef.current ? '#video-embed' : '#video-main'); const videoElement = document.querySelector(inEmbedRef.current ? '#video-embed' : '#video-main');
if (videoElement) { if (videoElement) {
videoElement.scrollIntoView({ const urlScroll = getUrlParameter('scroll');
behavior: 'smooth', const isIframe = window.parent !== window;
block: 'center',
inline: 'nearest' // Only scroll if not in an iframe, OR if explicitly requested via scroll=1 parameter
}); if (!isIframe || urlScroll === '1' || urlScroll === 'true') {
videoElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}
} }
} else { } else {
console.warn('VideoJS player not found for timestamp navigation'); console.warn('VideoJS player not found for timestamp navigation');
@@ -220,7 +236,14 @@ const VideoJSEmbed = ({
return ( return (
<div className="video-js-wrapper" ref={containerRef}> <div className="video-js-wrapper" ref={containerRef}>
{inEmbed ? <div id="video-js-root-embed" className="video-js-root-embed" /> : <div id="video-js-root-main" className="video-js-root-main" />} {inEmbed ? (
<div
id="video-js-root-embed"
className="video-js-root-embed"
/>
) : (
<div id="video-js-root-main" className="video-js-root-main" />
)}
</div> </div>
); );
}; };

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>

View File

@@ -4,10 +4,32 @@ import { LinksContext, SiteConsumer } from '../../utils/contexts/';
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 { CircleIconButton, MaterialIcon, NumericInputWithUnit } from '../_shared/'; import { CircleIconButton, MaterialIcon, NumericInputWithUnit } from '../_shared/';
import VideoViewer from '../media-viewer/VideoViewer';
const EMBED_OPTIONS_STORAGE_KEY = 'mediacms_embed_options';
function loadEmbedOptions() {
try {
const saved = localStorage.getItem(EMBED_OPTIONS_STORAGE_KEY);
if (saved) {
return JSON.parse(saved);
}
} catch (e) {
// Ignore localStorage errors
}
return null;
}
function saveEmbedOptions(options) {
try {
localStorage.setItem(EMBED_OPTIONS_STORAGE_KEY, JSON.stringify(options));
} catch (e) {
// Ignore localStorage errors
}
}
export function MediaShareEmbed(props) { export function MediaShareEmbed(props) {
const embedVideoDimensions = PageStore.get('config-options').embedded.video.dimensions; const embedVideoDimensions = PageStore.get('config-options').embedded.video.dimensions;
const savedOptions = loadEmbedOptions();
const links = useContext(LinksContext); const links = useContext(LinksContext);
@@ -18,12 +40,19 @@ export function MediaShareEmbed(props) {
const onRightBottomRef = useRef(null); const onRightBottomRef = useRef(null);
const [maxHeight, setMaxHeight] = useState(window.innerHeight - 144 + 56); const [maxHeight, setMaxHeight] = useState(window.innerHeight - 144 + 56);
const [keepAspectRatio, setKeepAspectRatio] = useState(false); const [keepAspectRatio, setKeepAspectRatio] = useState(savedOptions?.keepAspectRatio ?? true);
const [aspectRatio, setAspectRatio] = useState('16:9'); const [showTitle, setShowTitle] = useState(savedOptions?.showTitle ?? true);
const [embedWidthValue, setEmbedWidthValue] = useState(embedVideoDimensions.width); const [showRelated, setShowRelated] = useState(savedOptions?.showRelated ?? true);
const [embedWidthUnit, setEmbedWidthUnit] = useState(embedVideoDimensions.widthUnit); const [showUserAvatar, setShowUserAvatar] = useState(savedOptions?.showUserAvatar ?? true);
const [embedHeightValue, setEmbedHeightValue] = useState(embedVideoDimensions.height); const [linkTitle, setLinkTitle] = useState(savedOptions?.linkTitle ?? true);
const [embedHeightUnit, setEmbedHeightUnit] = useState(embedVideoDimensions.heightUnit); const [responsive, setResponsive] = useState(savedOptions?.responsive ?? false);
const [startAt, setStartAt] = useState(false);
const [startTime, setStartTime] = useState('0:00');
const [aspectRatio, setAspectRatio] = useState(savedOptions?.aspectRatio ?? '16:9');
const [embedWidthValue, setEmbedWidthValue] = useState(savedOptions?.embedWidthValue ?? embedVideoDimensions.width);
const [embedWidthUnit, setEmbedWidthUnit] = useState(savedOptions?.embedWidthUnit ?? embedVideoDimensions.widthUnit);
const [embedHeightValue, setEmbedHeightValue] = useState(savedOptions?.embedHeightValue ?? embedVideoDimensions.height);
const [embedHeightUnit, setEmbedHeightUnit] = useState(savedOptions?.embedHeightUnit ?? embedVideoDimensions.heightUnit);
const [rightMiddlePositionTop, setRightMiddlePositionTop] = useState(60); const [rightMiddlePositionTop, setRightMiddlePositionTop] = useState(60);
const [rightMiddlePositionBottom, setRightMiddlePositionBottom] = useState(60); const [rightMiddlePositionBottom, setRightMiddlePositionBottom] = useState(60);
const [unitOptions, setUnitOptions] = useState([ const [unitOptions, setUnitOptions] = useState([
@@ -71,36 +100,65 @@ export function MediaShareEmbed(props) {
setEmbedHeightUnit(newVal); setEmbedHeightUnit(newVal);
} }
function onKeepAspectRatioChange() { function onShowTitleChange() {
const newVal = !keepAspectRatio; setShowTitle(!showTitle);
}
const arr = aspectRatio.split(':'); function onShowRelatedChange() {
const x = arr[0]; setShowRelated(!showRelated);
const y = arr[1]; }
setKeepAspectRatio(newVal); function onShowUserAvatarChange() {
setEmbedWidthUnit(newVal ? 'px' : embedWidthUnit); setShowUserAvatar(!showUserAvatar);
setEmbedHeightUnit(newVal ? 'px' : embedHeightUnit); }
setEmbedHeightValue(newVal ? parseInt((embedWidthValue * y) / x, 10) : embedHeightValue);
setUnitOptions( function onLinkTitleChange() {
newVal setLinkTitle(!linkTitle);
? [{ key: 'px', label: 'px' }] }
: [
{ key: 'px', label: 'px' }, function onResponsiveChange() {
{ key: 'percent', label: '%' }, const nextResponsive = !responsive;
] setResponsive(nextResponsive);
);
if (!nextResponsive) {
if (aspectRatio !== 'custom') {
const arr = aspectRatio.split(':');
const x = arr[0];
const y = arr[1];
setKeepAspectRatio(true);
setEmbedHeightValue(parseInt((embedWidthValue * y) / x, 10));
} else {
setKeepAspectRatio(false);
}
} else {
setKeepAspectRatio(false);
}
}
function onStartAtChange() {
setStartAt(!startAt);
}
function onStartTimeChange(e) {
setStartTime(e.target.value);
} }
function onAspectRatioChange() { function onAspectRatioChange() {
const newVal = aspectRatioValueRef.current.value; const newVal = aspectRatioValueRef.current.value;
const arr = newVal.split(':'); if (newVal === 'custom') {
const x = arr[0]; setAspectRatio(newVal);
const y = arr[1]; setKeepAspectRatio(false);
} else {
const arr = newVal.split(':');
const x = arr[0];
const y = arr[1];
setAspectRatio(newVal); setAspectRatio(newVal);
setEmbedHeightValue(keepAspectRatio ? parseInt((embedWidthValue * y) / x, 10) : embedHeightValue); setKeepAspectRatio(true);
setEmbedHeightValue(parseInt((embedWidthValue * y) / x, 10));
}
} }
function onWindowResize() { function onWindowResize() {
@@ -130,13 +188,88 @@ export function MediaShareEmbed(props) {
}; };
}, []); }, []);
// Save embed options to localStorage when they change (except startAt/startTime)
useEffect(() => {
saveEmbedOptions({
showTitle,
showRelated,
showUserAvatar,
linkTitle,
responsive,
aspectRatio,
embedWidthValue,
embedWidthUnit,
embedHeightValue,
embedHeightUnit,
keepAspectRatio,
});
}, [showTitle, showRelated, showUserAvatar, linkTitle, responsive, aspectRatio, embedWidthValue, embedWidthUnit, embedHeightValue, embedHeightUnit, keepAspectRatio]);
function getEmbedCode() {
const mediaId = MediaPageStore.get('media-id');
const params = new URLSearchParams();
if (showTitle) params.set('showTitle', '1');
else params.set('showTitle', '0');
if (showRelated) params.set('showRelated', '1');
else params.set('showRelated', '0');
if (showUserAvatar) params.set('showUserAvatar', '1');
else params.set('showUserAvatar', '0');
if (linkTitle) params.set('linkTitle', '1');
else params.set('linkTitle', '0');
if (startAt && startTime) {
const parts = startTime.split(':').reverse();
let seconds = 0;
if (parts[0]) seconds += parseInt(parts[0], 10) || 0;
if (parts[1]) seconds += (parseInt(parts[1], 10) || 0) * 60;
if (parts[2]) seconds += (parseInt(parts[2], 10) || 0) * 3600;
if (seconds > 0) params.set('t', seconds);
}
const separator = links.embed.includes('?') ? '&' : '?';
const finalUrl = `${links.embed}${mediaId}${separator}${params.toString()}`;
if (responsive) {
if (aspectRatio === 'custom') {
// Use current width/height values to calculate aspect ratio for custom
const ratio = `${embedWidthValue} / ${embedHeightValue}`;
const maxWidth = `calc(100vh * ${embedWidthValue} / ${embedHeightValue})`;
return `<iframe src="${finalUrl}" style="width:100%;max-width:${maxWidth};aspect-ratio:${ratio};display:block;margin:auto;border:0;" allowFullScreen></iframe>`;
}
const arr = aspectRatio.split(':');
const ratio = `${arr[0]} / ${arr[1]}`;
const maxWidth = `calc(100vh * ${arr[0]} / ${arr[1]})`;
return `<iframe src="${finalUrl}" style="width:100%;max-width:${maxWidth};aspect-ratio:${ratio};display:block;margin:auto;border:0;" allowFullScreen></iframe>`;
}
const width = 'percent' === embedWidthUnit ? embedWidthValue + '%' : embedWidthValue;
const height = 'percent' === embedHeightUnit ? embedHeightValue + '%' : embedHeightValue;
return `<iframe width="${width}" height="${height}" src="${finalUrl}" frameBorder="0" allowFullScreen></iframe>`;
}
return ( return (
<div className="share-embed" style={{ maxHeight: maxHeight + 'px' }}> <div className="share-embed" style={{ maxHeight: maxHeight + 'px' }}>
<div className="share-embed-inner"> <div className="share-embed-inner">
<div className="on-left"> <div className="on-left">
<div className="media-embed-wrap"> <div className="media-embed-wrap">
<SiteConsumer> <SiteConsumer>
{(site) => <VideoViewer data={MediaPageStore.get('media-data')} siteUrl={site.url} inEmbed={true} />} {(site) => {
const previewUrl = `${links.embed + MediaPageStore.get('media-id')}&showTitle=${showTitle ? '1' : '0'}&showRelated=${showRelated ? '1' : '0'}&showUserAvatar=${showUserAvatar ? '1' : '0'}&linkTitle=${linkTitle ? '1' : '0'}${startAt ? '&t=' + (startTime.split(':').reverse().reduce((acc, cur, i) => acc + (parseInt(cur, 10) || 0) * Math.pow(60, i), 0)) : ''}`;
const style = {};
style.width = '100%';
style.height = '480px';
style.overflow = 'hidden';
return (
<div style={style}>
<iframe width="100%" height="100%" src={previewUrl} frameBorder="0" allowFullScreen></iframe>
</div>
);
}}
</SiteConsumer> </SiteConsumer>
</div> </div>
</div> </div>
@@ -158,16 +291,7 @@ export function MediaShareEmbed(props) {
> >
<textarea <textarea
readOnly readOnly
value={ value={getEmbedCode()}
'<iframe width="' +
('percent' === embedWidthUnit ? embedWidthValue + '%' : embedWidthValue) +
'" height="' +
('percent' === embedHeightUnit ? embedHeightValue + '%' : embedHeightValue) +
'" src="' +
links.embed +
MediaPageStore.get('media-id') +
'" frameborder="0" allowfullscreen></iframe>'
}
></textarea> ></textarea>
<div className="iframe-config"> <div className="iframe-config">
@@ -179,59 +303,106 @@ export function MediaShareEmbed(props) {
</div>*/} </div>*/}
<div className="option-content"> <div className="option-content">
<div className="ratio-options"> <div className="ratio-options" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 10px' }}>
<div className="options-group"> <div className="options-group">
<label style={{ minHeight: '36px' }}> <label style={{ minHeight: '36px', whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={keepAspectRatio} onChange={onKeepAspectRatioChange} /> <input type="checkbox" checked={showTitle} onChange={onShowTitleChange} />
Keep aspect ratio Show title
</label> </label>
</div> </div>
{!keepAspectRatio ? null : ( <div className="options-group">
<div className="options-group"> <label style={{ minHeight: '36px', whiteSpace: 'nowrap', opacity: showTitle ? 1 : 0.5 }}>
<select ref={aspectRatioValueRef} onChange={onAspectRatioChange} value={aspectRatio}> <input type="checkbox" checked={linkTitle} onChange={onLinkTitleChange} disabled={!showTitle} />
<optgroup label="Horizontal orientation"> Link title
<option value="16:9">16:9</option> </label>
<option value="4:3">4:3</option> </div>
<option value="3:2">3:2</option>
</optgroup> <div className="options-group">
<optgroup label="Vertical orientation"> <label style={{ minHeight: '36px', whiteSpace: 'nowrap' }}>
<option value="9:16">9:16</option> <input type="checkbox" checked={showRelated} onChange={onShowRelatedChange} />
<option value="3:4">3:4</option> Show related
<option value="2:3">2:3</option> </label>
</optgroup> </div>
<div className="options-group">
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', opacity: showTitle ? 1 : 0.5 }}>
<input type="checkbox" checked={showUserAvatar} onChange={onShowUserAvatarChange} disabled={!showTitle} />
Show user avatar
</label>
</div>
<div className="options-group" style={{ display: 'flex', alignItems: 'center' }}>
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', marginRight: '10px' }}>
<input type="checkbox" checked={responsive} onChange={onResponsiveChange} />
Responsive
</label>
</div>
<div className="options-group" style={{ display: 'flex', alignItems: 'center' }}>
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', marginRight: '10px' }}>
<input type="checkbox" checked={startAt} onChange={onStartAtChange} />
Start at
</label>
{startAt && (
<input
type="text"
value={startTime}
onChange={onStartTimeChange}
style={{ width: '60px', height: '28px', fontSize: '12px', padding: '2px 5px' }}
/>
)}
</div>
<div className="options-group" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<div style={{ fontSize: '12px', marginBottom: '4px', color: 'rgba(0,0,0,0.6)' }}>Aspect Ratio</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<select
ref={aspectRatioValueRef}
onChange={onAspectRatioChange}
value={aspectRatio}
style={{ height: '28px', fontSize: '12px' }}
>
<option value="16:9">16:9</option>
<option value="4:3">4:3</option>
<option value="3:2">3:2</option>
<option value="custom">Custom</option>
</select> </select>
</div> </div>
)} </div>
</div> </div>
<br /> <br />
<div className="options-group"> {!responsive && (
<NumericInputWithUnit <>
valueCallback={onEmbedWidthValueChange} <div className="options-group">
unitCallback={onEmbedWidthUnitChange} <NumericInputWithUnit
label={'Width'} valueCallback={onEmbedWidthValueChange}
defaultValue={parseInt(embedWidthValue, 10)} unitCallback={onEmbedWidthUnitChange}
defaultUnit={embedWidthUnit} label={'Width'}
minValue={1} defaultValue={parseInt(embedWidthValue, 10)}
maxValue={99999} defaultUnit={embedWidthUnit}
units={unitOptions} minValue={1}
/> maxValue={99999}
</div> units={unitOptions}
/>
</div>
<div className="options-group"> <div className="options-group">
<NumericInputWithUnit <NumericInputWithUnit
valueCallback={onEmbedHeightValueChange} valueCallback={onEmbedHeightValueChange}
unitCallback={onEmbedHeightUnitChange} unitCallback={onEmbedHeightUnitChange}
label={'Height'} label={'Height'}
defaultValue={parseInt(embedHeightValue, 10)} defaultValue={parseInt(embedHeightValue, 10)}
defaultUnit={embedHeightUnit} defaultUnit={embedHeightUnit}
minValue={1} minValue={1}
maxValue={99999} maxValue={99999}
units={unitOptions} units={unitOptions}
/> />
</div> </div>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View File

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

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

@@ -54,6 +54,10 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
? formatInnerLink(MediaPageStore.get('media-original-url'), SiteContext._currentValue.url) ? formatInnerLink(MediaPageStore.get('media-original-url'), SiteContext._currentValue.url)
: null; : null;
// Extract actual filename from URL for non-video downloads
const originalUrl = MediaPageStore.get('media-original-url');
this.downloadFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : this.props.title;
this.updateStateValues = this.updateStateValues.bind(this); this.updateStateValues = this.updateStateValues.bind(this);
} }
@@ -104,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 = '';
@@ -117,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
@@ -125,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}
@@ -171,7 +192,7 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
.downloadLink ? ( .downloadLink ? (
<VideoMediaDownloadLink /> <VideoMediaDownloadLink />
) : ( ) : (
<OtherMediaDownloadLink link={this.downloadLink} title={this.props.title} /> <OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} />
)} )}
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} /> <MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />

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.props.title} />
)}
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
</div>
</div>
</div>
</div>
);
}
} }

View File

@@ -410,8 +410,12 @@ export default class VideoViewer extends React.PureComponent {
poster: this.videoPoster, poster: this.videoPoster,
previewSprite: previewSprite, previewSprite: previewSprite,
subtitlesInfo: this.props.data.subtitles_info, subtitlesInfo: this.props.data.subtitles_info,
enableAutoplay: !this.props.inEmbed,
inEmbed: this.props.inEmbed, inEmbed: this.props.inEmbed,
showTitle: this.props.showTitle,
showRelated: this.props.showRelated,
showUserAvatar: this.props.showUserAvatar,
linkTitle: this.props.linkTitle,
urlTimestamp: this.props.timestamp,
hasTheaterMode: !this.props.inEmbed, hasTheaterMode: !this.props.inEmbed,
hasNextLink: !!nextLink, hasNextLink: !!nextLink,
nextLink: nextLink, nextLink: nextLink,
@@ -435,9 +439,19 @@ export default class VideoViewer extends React.PureComponent {
VideoViewer.defaultProps = { VideoViewer.defaultProps = {
inEmbed: !0, inEmbed: !0,
showTitle: !0,
showRelated: !0,
showUserAvatar: !0,
linkTitle: !0,
timestamp: null,
siteUrl: PropTypes.string.isRequired, siteUrl: PropTypes.string.isRequired,
}; };
VideoViewer.propTypes = { VideoViewer.propTypes = {
inEmbed: PropTypes.bool, inEmbed: PropTypes.bool,
showTitle: PropTypes.bool,
showRelated: PropTypes.bool,
showUserAvatar: PropTypes.bool,
linkTitle: PropTypes.bool,
timestamp: PropTypes.number,
}; };

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

@@ -41,7 +41,7 @@ export const EmbedPage: React.FC = () => {
}, []); }, []);
return ( return (
<div className="embed-wrap" style={wrapperStyles}> <div className="embed-wrap media-embed-wrap" style={wrapperStyles}>
{failedMediaLoad && ( {failedMediaLoad && (
<div className="player-container player-container-error" style={containerStyles}> <div className="player-container player-container-error" style={containerStyles}>
<div className="player-container-inner" style={containerStyles}> <div className="player-container-inner" style={containerStyles}>
@@ -59,9 +59,32 @@ export const EmbedPage: React.FC = () => {
{loadedVideo && ( {loadedVideo && (
<SiteConsumer> <SiteConsumer>
{(site) => ( {(site) => {
<VideoViewer data={MediaPageStore.get('media-data')} siteUrl={site.url} containerStyles={containerStyles} /> const urlParams = new URLSearchParams(window.location.search);
)} const urlShowTitle = urlParams.get('showTitle');
const showTitle = urlShowTitle !== '0';
const urlShowRelated = urlParams.get('showRelated');
const showRelated = urlShowRelated !== '0';
const urlShowUserAvatar = urlParams.get('showUserAvatar');
const showUserAvatar = urlShowUserAvatar !== '0';
const urlLinkTitle = urlParams.get('linkTitle');
const linkTitle = urlLinkTitle !== '0';
const urlTimestamp = urlParams.get('t');
const timestamp = urlTimestamp ? parseInt(urlTimestamp, 10) : null;
return (
<VideoViewer
data={MediaPageStore.get('media-data')}
siteUrl={site.url}
containerStyles={containerStyles}
showTitle={showTitle}
showRelated={showRelated}
showUserAvatar={showUserAvatar}
linkTitle={linkTitle}
timestamp={timestamp}
/>
);
}}
</SiteConsumer> </SiteConsumer>
)} )}
</div> </div>

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

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

View File

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

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