Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
223e87073f | ||
|
|
1c15880ae3 | ||
|
|
ed5cfa1a84 | ||
|
|
2fe48d8522 | ||
|
|
90331f3b4a | ||
|
|
c57f528ab1 | ||
|
|
fa67ffffb4 | ||
|
|
872571350f | ||
|
|
665971856b | ||
|
|
d9b1d6cab1 | ||
|
|
aeef8284bf | ||
|
|
a90fcbf8dd | ||
|
|
1b3cdfd302 | ||
|
|
cd7dd4f72c | ||
|
|
9b3d9fe1e7 | ||
|
|
ea340b6a2e | ||
|
|
ba2c31b1e6 | ||
|
|
5eb6fafb8c | ||
|
|
c035bcddf5 | ||
|
|
01912ea1f9 | ||
|
|
d9f299af4d |
@ -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/**
|
||||||
|
|||||||
42
.github/workflows/frontend-build-and-test.yml
vendored
Normal file
42
.github/workflows/frontend-build-and-test.yml
vendored
Normal 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
8
.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -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**
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
VERSION = "7.2.0"
|
VERSION = "7.7"
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
107
files/forms.py
107
files/forms.py
@ -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
|
||||||
|
|||||||
@ -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": "مشاركة",
|
||||||
|
|||||||
@ -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": "শেয়ার করুন",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "ΚΟΙΝΟΠΟΙΗΣΗ",
|
||||||
|
|||||||
@ -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": "",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "שתף",
|
||||||
|
|||||||
@ -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": "साझा करें",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "共有",
|
||||||
|
|||||||
@ -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": "공유",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "ПОДЕЛИТЬСЯ",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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Ş",
|
||||||
|
|||||||
@ -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": "شیئر کریں",
|
||||||
|
|||||||
@ -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": "分享",
|
||||||
|
|||||||
@ -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": "分享",
|
||||||
|
|||||||
@ -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]
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
24
files/migrations/0014_alter_subtitle_options_and_more.py
Normal file
24
files/migrations/0014_alter_subtitle_options_and_more.py
Normal 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'},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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
39
files/widgets.py
Normal 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)
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -20,7 +20,7 @@ const useVideoChapters = () => {
|
|||||||
// Sort by start time to find chronological position
|
// Sort by start time to find chronological position
|
||||||
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
|
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
|
||||||
// Find the index of our new segment
|
// Find the index of our new segment
|
||||||
const chapterIndex = sortedSegments.findIndex(seg => seg.startTime === newSegmentStartTime);
|
const chapterIndex = sortedSegments.findIndex((seg) => seg.startTime === newSegmentStartTime);
|
||||||
return `Chapter ${chapterIndex + 1}`;
|
return `Chapter ${chapterIndex + 1}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -30,10 +30,16 @@ const useVideoChapters = () => {
|
|||||||
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
// Renumber each segment based on its chronological position
|
// Renumber each segment based on its chronological position
|
||||||
return sortedSegments.map((segment, index) => ({
|
// Only update titles that follow the default "Chapter X" pattern to preserve custom titles
|
||||||
...segment,
|
return sortedSegments.map((segment, index) => {
|
||||||
chapterTitle: `Chapter ${index + 1}`
|
const currentTitle = segment.chapterTitle || '';
|
||||||
}));
|
const isDefaultTitle = /^Chapter \d+$/.test(currentTitle);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...segment,
|
||||||
|
chapterTitle: isDefaultTitle ? `Chapter ${index + 1}` : currentTitle,
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
|
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
|
||||||
@ -55,6 +61,9 @@ const useVideoChapters = () => {
|
|||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
|
||||||
|
// Track if editor has been initialized to prevent re-initialization on Safari metadata events
|
||||||
|
const isInitializedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
// Timeline state
|
// Timeline state
|
||||||
const [trimStart, setTrimStart] = useState(0);
|
const [trimStart, setTrimStart] = useState(0);
|
||||||
const [trimEnd, setTrimEnd] = useState(0);
|
const [trimEnd, setTrimEnd] = useState(0);
|
||||||
@ -102,11 +111,7 @@ const useVideoChapters = () => {
|
|||||||
// Detect Safari browser
|
// Detect Safari browser
|
||||||
const isSafari = () => {
|
const isSafari = () => {
|
||||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||||
const isSafariBrowser = /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
|
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
|
||||||
if (isSafariBrowser) {
|
|
||||||
logger.debug('Safari browser detected, enabling audio support fallbacks');
|
|
||||||
}
|
|
||||||
return isSafariBrowser;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize video event listeners
|
// Initialize video event listeners
|
||||||
@ -115,7 +120,15 @@ const useVideoChapters = () => {
|
|||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
const handleLoadedMetadata = () => {
|
const handleLoadedMetadata = () => {
|
||||||
logger.debug('Video loadedmetadata event fired, duration:', video.duration);
|
// CRITICAL: Prevent re-initialization if editor has already been initialized
|
||||||
|
// Safari fires loadedmetadata multiple times, which was resetting segments
|
||||||
|
if (isInitializedRef.current) {
|
||||||
|
// Still update duration and trimEnd in case they changed
|
||||||
|
setDuration(video.duration);
|
||||||
|
setTrimEnd(video.duration);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setDuration(video.duration);
|
setDuration(video.duration);
|
||||||
setTrimEnd(video.duration);
|
setTrimEnd(video.duration);
|
||||||
|
|
||||||
@ -124,9 +137,7 @@ const useVideoChapters = () => {
|
|||||||
let initialSegments: Segment[] = [];
|
let initialSegments: Segment[] = [];
|
||||||
|
|
||||||
// Check if we have existing chapters from the backend
|
// Check if we have existing chapters from the backend
|
||||||
const existingChapters =
|
const existingChapters = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || [];
|
||||||
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) ||
|
|
||||||
[];
|
|
||||||
|
|
||||||
if (existingChapters.length > 0) {
|
if (existingChapters.length > 0) {
|
||||||
// Create segments from existing chapters
|
// Create segments from existing chapters
|
||||||
@ -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);
|
||||||
@ -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 = {
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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
9
frontend/jest.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import("jest").Config} **/
|
||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': 'ts-jest',
|
||||||
|
'^.+\\.jsx?$': 'babel-jest',
|
||||||
|
},
|
||||||
|
collectCoverageFrom: ['src/**'],
|
||||||
|
};
|
||||||
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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)') },
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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
@ -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">
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
20
frontend/src/static/js/utils/helpers/embeddedApp.ts
Normal file
20
frontend/src/static/js/utils/helpers/embeddedApp.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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] +
|
||||||
};
|
').',
|
||||||
|
]);
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
19
frontend/src/static/js/utils/hoc/withBulkActions.jsx
Normal file
19
frontend/src/static/js/utils/hoc/withBulkActions.jsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
frontend/tests/utils/helpers/csrfToken.test.ts
Normal file
56
frontend/tests/utils/helpers/csrfToken.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
220
frontend/tests/utils/helpers/dom.test.ts
Normal file
220
frontend/tests/utils/helpers/dom.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
47
frontend/tests/utils/helpers/errors.test.ts
Normal file
47
frontend/tests/utils/helpers/errors.test.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// Mock the './log' module used by errors.ts to capture calls without console side effects
|
||||||
|
jest.mock('../../../src/static/js/utils/helpers/log', () => ({ error: jest.fn(), warn: jest.fn() }));
|
||||||
|
|
||||||
|
import { logErrorAndReturnError, logWarningAndReturnError } from '../../../src/static/js/utils/helpers/errors';
|
||||||
|
import { error as mockedError, warn as mockedWarn } from '../../../src/static/js/utils/helpers/log';
|
||||||
|
|
||||||
|
describe('js/utils/helpers', () => {
|
||||||
|
describe('errors', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logErrorAndReturnError returns Error with first message and logs with error', () => {
|
||||||
|
const messages = ['Primary msg', 'details', 'more'];
|
||||||
|
const err = logErrorAndReturnError(messages);
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(err.message).toBe('Primary msg');
|
||||||
|
expect(mockedError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedError).toHaveBeenCalledWith(...messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logWarningAndReturnError returns Error with first message and logs with warn', () => {
|
||||||
|
const messages = ['Primary msg', 'details', 'more'];
|
||||||
|
const err = logWarningAndReturnError(messages);
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(err.message).toBe('Primary msg');
|
||||||
|
expect(mockedWarn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedWarn).toHaveBeenCalledWith(...messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Handles empty array creating an Error with undefined message and logs called with no args', () => {
|
||||||
|
const messages: string[] = [];
|
||||||
|
|
||||||
|
const err1 = logErrorAndReturnError(messages);
|
||||||
|
expect(err1).toBeInstanceOf(Error);
|
||||||
|
expect(err1.message).toBe('');
|
||||||
|
expect(mockedError).toHaveBeenCalledWith('');
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
const err2 = logWarningAndReturnError(messages);
|
||||||
|
expect(err2).toBeInstanceOf(Error);
|
||||||
|
expect(err2.message).toBe('');
|
||||||
|
expect(mockedWarn).toHaveBeenCalledWith('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
44
frontend/tests/utils/helpers/exportStore.test.ts
Normal file
44
frontend/tests/utils/helpers/exportStore.test.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
// Mock the dispatcher module used by exportStore
|
||||||
|
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ register: jest.fn() }));
|
||||||
|
|
||||||
|
import exportStore from '../../../src/static/js/utils/helpers/exportStore';
|
||||||
|
|
||||||
|
// Re-import the mocked dispatcher for assertions
|
||||||
|
import * as dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||||
|
|
||||||
|
describe('js/utils/helpers', () => {
|
||||||
|
describe('exportStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Registers store handler with dispatcher and binds context', () => {
|
||||||
|
const ctx: { value: number; inc?: () => void } = { value: 0 };
|
||||||
|
const handlerName = 'inc';
|
||||||
|
const handler = function (this: typeof ctx) {
|
||||||
|
this.value += 1;
|
||||||
|
};
|
||||||
|
ctx[handlerName] = handler as any;
|
||||||
|
|
||||||
|
const result = exportStore(ctx, handlerName);
|
||||||
|
|
||||||
|
// returns the same store instance
|
||||||
|
expect(result).toBe(ctx);
|
||||||
|
|
||||||
|
// Ensure dispatcher.register was called once with a bound function
|
||||||
|
expect((dispatcher as any).register).toHaveBeenCalledTimes(1);
|
||||||
|
const registeredFn = (dispatcher as any).register.mock.calls[0][0] as Function;
|
||||||
|
expect(typeof registeredFn).toBe('function');
|
||||||
|
|
||||||
|
// Verify the registered function is bound to the store context
|
||||||
|
registeredFn();
|
||||||
|
expect(ctx.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Throws if handler name does not exist on store', () => {
|
||||||
|
const store: any = {};
|
||||||
|
// Accessing store[handler] would be undefined; calling .bind on undefined would throw
|
||||||
|
expect(() => exportStore(store, 'missing')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
23
frontend/tests/utils/helpers/formatInnerLink.test.ts
Normal file
23
frontend/tests/utils/helpers/formatInnerLink.test.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { formatInnerLink } from '../../../src/static/js/utils/helpers/formatInnerLink';
|
||||||
|
|
||||||
|
describe('js/utils/helpers', () => {
|
||||||
|
describe('formatInnerLink', () => {
|
||||||
|
test('Returns the same absolute URL unchanged', () => {
|
||||||
|
const url = 'https://example.com/path?x=1#hash';
|
||||||
|
const base = 'https://base.example.org';
|
||||||
|
expect(formatInnerLink(url, base)).toBe(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Constructs absolute URL from relative path with leading slash', () => {
|
||||||
|
const url = '/images/picture.png';
|
||||||
|
const base = 'https://media.example.com';
|
||||||
|
expect(formatInnerLink(url, base)).toBe('https://media.example.com/images/picture.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Constructs absolute URL from relative path without leading slash', () => {
|
||||||
|
const url = 'assets/file.txt';
|
||||||
|
const base = 'https://cdn.example.com';
|
||||||
|
expect(formatInnerLink(url, base)).toBe('https://cdn.example.com/assets/file.txt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { formatManagementTableDate } from '../../../src/static/js/utils/helpers/formatManagementTableDate';
|
||||||
|
|
||||||
|
describe('js/utils/helpers', () => {
|
||||||
|
describe('formatManagementTableDate', () => {
|
||||||
|
test('Formats date with zero-padded time components', () => {
|
||||||
|
const d = new Date(2021, 0, 5, 3, 7, 9); // Jan=0, day 5, 03:07:09
|
||||||
|
expect(formatManagementTableDate(d)).toBe('Jan 5, 2021 03:07:09');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Formats date with double-digit time components and month abbreviation', () => {
|
||||||
|
const d = new Date(1999, 11, 31, 23, 59, 58); // Dec=11
|
||||||
|
expect(formatManagementTableDate(d)).toBe('Dec 31, 1999 23:59:58');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
106
frontend/tests/utils/helpers/formatViewsNumber.test.ts
Normal file
106
frontend/tests/utils/helpers/formatViewsNumber.test.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import formatViewsNumber from '../../../src/static/js/utils/helpers/formatViewsNumber';
|
||||||
|
|
||||||
|
describe('js/utils/helpers', () => {
|
||||||
|
describe('formatViewsNumber', () => {
|
||||||
|
describe('fullNumber = false (default compact formatting)', () => {
|
||||||
|
test('Formats values < 1,000 without suffix and with correct rounding', () => {
|
||||||
|
expect(formatViewsNumber(0)).toBe('0');
|
||||||
|
expect(formatViewsNumber(9)).toBe('9');
|
||||||
|
expect(formatViewsNumber(12)).toBe('12');
|
||||||
|
expect(formatViewsNumber(999)).toBe('999');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Formats thousands to K with decimals for < 10K and none for >= 10K', () => {
|
||||||
|
expect(formatViewsNumber(1000)).toBe('1K');
|
||||||
|
expect(formatViewsNumber(1500)).toBe('1.5K');
|
||||||
|
expect(formatViewsNumber(1499)).toBe('1.5K'); // rounds to 1 decimal
|
||||||
|
expect(formatViewsNumber(10_000)).toBe('10K');
|
||||||
|
expect(formatViewsNumber(10_400)).toBe('10K');
|
||||||
|
expect(formatViewsNumber(10_500)).toBe('11K'); // rounds to nearest whole
|
||||||
|
expect(formatViewsNumber(99_900)).toBe('100K'); // rounding up
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Formats millions to M with decimals for < 10M and none for >= 10M', () => {
|
||||||
|
expect(formatViewsNumber(1_000_000)).toBe('1M');
|
||||||
|
expect(formatViewsNumber(1_200_000)).toBe('1.2M');
|
||||||
|
expect(formatViewsNumber(9_440_000)).toBe('9.4M');
|
||||||
|
expect(formatViewsNumber(9_960_000)).toBe('10M'); // rounds to whole when >= 10M threshold after rounding
|
||||||
|
expect(formatViewsNumber(10_000_000)).toBe('10M');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Formats billions and trillions correctly', () => {
|
||||||
|
expect(formatViewsNumber(1_000_000_000)).toBe('1B');
|
||||||
|
expect(formatViewsNumber(1_500_000_000)).toBe('1.5B');
|
||||||
|
expect(formatViewsNumber(10_000_000_000)).toBe('10B');
|
||||||
|
expect(formatViewsNumber(1_000_000_000_000)).toBe('1T');
|
||||||
|
expect(formatViewsNumber(1_230_000_000_000)).toBe('1.2T');
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: Revisit this behavior
|
||||||
|
test('Beyond last unit keeps using the last unit with scaling', () => {
|
||||||
|
// Implementation scales beyond units by increasing the value so that the last unit remains applicable
|
||||||
|
// Here, expect a number in T with rounding behavior similar to others
|
||||||
|
expect(formatViewsNumber(9_999_999_999_999)).toBe('10T');
|
||||||
|
// With current rounding rules, this value rounds to whole trillions
|
||||||
|
expect(formatViewsNumber(12_345_678_901_234)).toBe('12T');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fullNumber = true (locale formatting)', () => {
|
||||||
|
test('Returns locale string representation of the full number', () => {
|
||||||
|
// Use a fixed locale independent assertion by stripping non-digits except separators that could vary.
|
||||||
|
// However, to avoid locale variance, check that it equals toLocaleString directly.
|
||||||
|
const vals = [0, 12, 999, 1000, 1234567, 9876543210];
|
||||||
|
for (const v of vals) {
|
||||||
|
expect(formatViewsNumber(v, true)).toBe(v.toLocaleString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Additional edge cases and robustness', () => {
|
||||||
|
test('Handles negative values without unit suffix (no scaling applied)', () => {
|
||||||
|
expect(formatViewsNumber(-999)).toBe('-999');
|
||||||
|
expect(formatViewsNumber(-1000)).toBe('-1000');
|
||||||
|
expect(formatViewsNumber(-1500)).toBe('-1500');
|
||||||
|
expect(formatViewsNumber(-10_500)).toBe('-10500');
|
||||||
|
expect(formatViewsNumber(-1_230_000_000_000)).toBe('-1230000000000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Handles non-integer inputs with correct rounding in compact mode', () => {
|
||||||
|
expect(formatViewsNumber(1499.5)).toBe('1.5K');
|
||||||
|
expect(formatViewsNumber(999.9)).toBe('1000');
|
||||||
|
expect(formatViewsNumber(10_499.5)).toBe('10K');
|
||||||
|
expect(formatViewsNumber(10_500.49)).toBe('11K');
|
||||||
|
expect(formatViewsNumber(9_440_000.49)).toBe('9.4M');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Respects locale formatting in fullNumber mode', () => {
|
||||||
|
const values = [1_234_567, -2_345_678, 0, 10_000, 99_999_999];
|
||||||
|
for (const v of values) {
|
||||||
|
expect(formatViewsNumber(v, true)).toBe(v.toLocaleString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Caps unit at trillions for extremely large numbers', () => {
|
||||||
|
expect(formatViewsNumber(9_999_999_999_999)).toBe('10T');
|
||||||
|
expect(formatViewsNumber(12_345_678_901_234)).toBe('12T');
|
||||||
|
expect(formatViewsNumber(987_654_321_000_000)).toBe('988T');
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: Revisit this behavior
|
||||||
|
test('Handles NaN and Infinity values gracefully', () => {
|
||||||
|
expect(formatViewsNumber(Number.NaN, true)).toBe(Number.NaN.toLocaleString());
|
||||||
|
expect(formatViewsNumber(Number.POSITIVE_INFINITY, true)).toBe(
|
||||||
|
Number.POSITIVE_INFINITY.toLocaleString()
|
||||||
|
);
|
||||||
|
expect(formatViewsNumber(Number.NEGATIVE_INFINITY, true)).toBe(
|
||||||
|
Number.NEGATIVE_INFINITY.toLocaleString()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(formatViewsNumber(Number.NaN)).toBe('NaN');
|
||||||
|
|
||||||
|
// @note: We don't test compact Infinity cases due to infinite loop behavior from while (views >= compare)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
47
frontend/tests/utils/helpers/imageExtension.test.ts
Normal file
47
frontend/tests/utils/helpers/imageExtension.test.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { imageExtension } from '../../../src/static/js/utils/helpers/imageExtension';
|
||||||
|
|
||||||
|
describe('js/utils/helpers', () => {
|
||||||
|
describe('imageExtension', () => {
|
||||||
|
// @todo: 'imageExtension' behaves as a 'fileExtension' function. It should be renamed...
|
||||||
|
test('Returns the extension for a typical filename', () => {
|
||||||
|
expect(imageExtension('photo.png')).toBe('png');
|
||||||
|
expect(imageExtension('document.pdf')).toBe('pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns the last segment for filenames with multiple dots', () => {
|
||||||
|
expect(imageExtension('archive.tar.gz')).toBe('gz');
|
||||||
|
expect(imageExtension('backup.2024.12.31.zip')).toBe('zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: It shouldn't happen. Fix that
|
||||||
|
test('Returns the entire string when there is no dot in the filename', () => {
|
||||||
|
expect(imageExtension('file')).toBe('file');
|
||||||
|
expect(imageExtension('README')).toBe('README');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Handles hidden files that start with a dot', () => {
|
||||||
|
expect(imageExtension('.gitignore')).toBe('gitignore');
|
||||||
|
expect(imageExtension('.env.local')).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns undefined for falsy or empty inputs', () => {
|
||||||
|
expect(imageExtension('')).toBeUndefined();
|
||||||
|
expect(imageExtension(undefined as unknown as string)).toBeUndefined();
|
||||||
|
expect(imageExtension(null as unknown as string)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Extracts the extension from URL-like paths', () => {
|
||||||
|
expect(imageExtension('https://example.com/images/avatar.jpeg')).toBe('jpeg');
|
||||||
|
expect(imageExtension('/static/assets/icons/favicon.ico')).toBe('ico');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Preserves case of the extension', () => {
|
||||||
|
expect(imageExtension('UPPER.CASE.JPG')).toBe('JPG');
|
||||||
|
expect(imageExtension('Mixed.Extension.PnG')).toBe('PnG');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns empty string when the filename ends with a trailing dot', () => {
|
||||||
|
expect(imageExtension('weird.')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
54
frontend/tests/utils/helpers/log.test.ts
Normal file
54
frontend/tests/utils/helpers/log.test.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { warn, error } from '../../../src/static/js/utils/helpers/log';
|
||||||
|
|
||||||
|
describe('js/utils/helpers', () => {
|
||||||
|
describe('log', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Setup console mocks - replaces global console methods with jest mocks
|
||||||
|
globalThis.console.warn = jest.fn();
|
||||||
|
globalThis.console.error = jest.fn();
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore original console methods
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Warn proxies arguments to console.warn preserving order and count', () => {
|
||||||
|
warn('a', 'b', 'c');
|
||||||
|
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(console.warn).toHaveBeenCalledWith('a', 'b', 'c');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Error proxies arguments to console.error preserving order and count', () => {
|
||||||
|
error('x', 'y');
|
||||||
|
expect(console.error).toHaveBeenCalledTimes(1);
|
||||||
|
expect(console.error).toHaveBeenCalledWith('x', 'y');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Warn supports zero arguments', () => {
|
||||||
|
warn();
|
||||||
|
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect((console.warn as jest.Mock).mock.calls[0].length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Error supports zero arguments', () => {
|
||||||
|
error();
|
||||||
|
expect(console.error).toHaveBeenCalledTimes(1);
|
||||||
|
expect((console.error as jest.Mock).mock.calls[0].length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Warn does not call console.error and error does not call console.warn', () => {
|
||||||
|
warn('only-warn');
|
||||||
|
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(console.error).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
error('only-error');
|
||||||
|
expect(console.error).toHaveBeenCalledTimes(1);
|
||||||
|
expect(console.warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
156
frontend/tests/utils/helpers/math.test.ts
Normal file
156
frontend/tests/utils/helpers/math.test.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import {
|
||||||
|
isGt,
|
||||||
|
isZero,
|
||||||
|
isNumber,
|
||||||
|
isInteger,
|
||||||
|
isPositive,
|
||||||
|
isPositiveNumber,
|
||||||
|
isPositiveInteger,
|
||||||
|
isPositiveIntegerOrZero,
|
||||||
|
greaterCommonDivision,
|
||||||
|
} from '../../../src/static/js/utils/helpers/math';
|
||||||
|
|
||||||
|
describe('js/utils/helpers', () => {
|
||||||
|
describe('math', () => {
|
||||||
|
describe('isGt', () => {
|
||||||
|
test('Returns true when x > y', () => {
|
||||||
|
expect(isGt(5, 3)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns false when x === y', () => {
|
||||||
|
expect(isGt(3, 3)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns false when x < y', () => {
|
||||||
|
expect(isGt(2, 3)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isZero', () => {
|
||||||
|
test('Returns true for 0', () => {
|
||||||
|
expect(isZero(0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns false for non-zero numbers', () => {
|
||||||
|
expect(isZero(1)).toBe(false);
|
||||||
|
expect(isZero(-1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNumber', () => {
|
||||||
|
test('Returns true for numbers', () => {
|
||||||
|
expect(isNumber(0)).toBe(true);
|
||||||
|
expect(isNumber(1)).toBe(true);
|
||||||
|
expect(isNumber(-1)).toBe(true);
|
||||||
|
expect(isNumber(1.5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns false for NaN', () => {
|
||||||
|
expect(isNumber(Number.NaN as unknown as number)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns false for non-number types (via casting)', () => {
|
||||||
|
// TypeScript type guards prevent passing non-numbers directly; simulate via casting
|
||||||
|
expect(isNumber('3' as unknown as number)).toBe(false);
|
||||||
|
expect(isNumber(null as unknown as number)).toBe(false);
|
||||||
|
expect(isNumber(undefined as unknown as number)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isInteger', () => {
|
||||||
|
test('Returns true for integers', () => {
|
||||||
|
expect(isInteger(0)).toBe(true);
|
||||||
|
expect(isInteger(1)).toBe(true);
|
||||||
|
expect(isInteger(-1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns false for non-integers', () => {
|
||||||
|
expect(isInteger(1.1)).toBe(false);
|
||||||
|
expect(isInteger(-2.5)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPositive', () => {
|
||||||
|
test('Returns true for positive numbers', () => {
|
||||||
|
expect(isPositive(1)).toBe(true);
|
||||||
|
expect(isPositive(3.14)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns false for zero and negatives', () => {
|
||||||
|
expect(isPositive(0)).toBe(false);
|
||||||
|
expect(isPositive(-1)).toBe(false);
|
||||||
|
expect(isPositive(-3.14)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPositiveNumber', () => {
|
||||||
|
test('Returns true for positive numbers', () => {
|
||||||
|
expect(isPositiveNumber(1)).toBe(true);
|
||||||
|
expect(isPositiveNumber(2.7)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns false for zero and negatives', () => {
|
||||||
|
expect(isPositiveNumber(0)).toBe(false);
|
||||||
|
expect(isPositiveNumber(-1)).toBe(false);
|
||||||
|
expect(isPositiveNumber(-3.4)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns false for NaN (and non-number when cast)', () => {
|
||||||
|
expect(isPositiveNumber(Number.NaN as unknown as number)).toBe(false);
|
||||||
|
expect(isPositiveNumber('3' as unknown as number)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPositiveInteger', () => {
|
||||||
|
test('Returns true for positive integers', () => {
|
||||||
|
expect(isPositiveInteger(1)).toBe(true);
|
||||||
|
expect(isPositiveInteger(10)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns false for zero, negatives, and non-integers', () => {
|
||||||
|
expect(isPositiveInteger(0)).toBe(false);
|
||||||
|
expect(isPositiveInteger(-1)).toBe(false);
|
||||||
|
expect(isPositiveInteger(1.5)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPositiveIntegerOrZero', () => {
|
||||||
|
test('Returns true for positive integers and zero', () => {
|
||||||
|
expect(isPositiveIntegerOrZero(0)).toBe(true);
|
||||||
|
expect(isPositiveIntegerOrZero(1)).toBe(true);
|
||||||
|
expect(isPositiveIntegerOrZero(10)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns false for negatives and non-integers', () => {
|
||||||
|
expect(isPositiveIntegerOrZero(-1)).toBe(false);
|
||||||
|
expect(isPositiveIntegerOrZero(1.1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('greaterCommonDivision', () => {
|
||||||
|
test('Computes gcd for positive integers', () => {
|
||||||
|
expect(greaterCommonDivision(54, 24)).toBe(6);
|
||||||
|
expect(greaterCommonDivision(24, 54)).toBe(6);
|
||||||
|
expect(greaterCommonDivision(21, 14)).toBe(7);
|
||||||
|
expect(greaterCommonDivision(7, 13)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Handles zeros', () => {
|
||||||
|
expect(greaterCommonDivision(0, 0)).toBe(0);
|
||||||
|
expect(greaterCommonDivision(0, 5)).toBe(5);
|
||||||
|
expect(greaterCommonDivision(12, 0)).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Handles negative numbers by returning gcd sign of first arg (Euclid recursion behavior)', () => {
|
||||||
|
expect(greaterCommonDivision(-54, 24)).toBe(-6);
|
||||||
|
expect(greaterCommonDivision(54, -24)).toBe(6);
|
||||||
|
expect(greaterCommonDivision(-54, -24)).toBe(-6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Works with equal numbers', () => {
|
||||||
|
expect(greaterCommonDivision(8, 8)).toBe(8);
|
||||||
|
expect(greaterCommonDivision(-8, -8)).toBe(-8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
111
frontend/tests/utils/helpers/propTypeFilters.test.ts
Normal file
111
frontend/tests/utils/helpers/propTypeFilters.test.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
// Mock the errors helper to capture error construction without side effects
|
||||||
|
jest.mock('../../../src/static/js/utils/helpers/errors', () => ({
|
||||||
|
logErrorAndReturnError: jest.fn((messages: string[]) => new Error(messages.join('\n'))),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { logErrorAndReturnError } from '../../../src/static/js/utils/helpers/errors';
|
||||||
|
import { PositiveIntegerOrZero, PositiveInteger } from '../../../src/static/js/utils/helpers/propTypeFilters';
|
||||||
|
|
||||||
|
describe('js/utils/helpers', () => {
|
||||||
|
describe('propTypeFilters', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PositiveIntegerOrZero', () => {
|
||||||
|
test('Returns null when property is undefined', () => {
|
||||||
|
const obj = {};
|
||||||
|
const res = PositiveIntegerOrZero(obj, 'count', 'Comp');
|
||||||
|
expect(res).toBeNull();
|
||||||
|
expect(logErrorAndReturnError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns null for zero or positive integers', () => {
|
||||||
|
const cases = [0, 1, 2, 100];
|
||||||
|
for (const val of cases) {
|
||||||
|
const res = PositiveIntegerOrZero({ count: val }, 'count', 'Comp');
|
||||||
|
expect(res).toBeNull();
|
||||||
|
}
|
||||||
|
expect(logErrorAndReturnError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns Error via logErrorAndReturnError for negative numbers', () => {
|
||||||
|
const res = PositiveIntegerOrZero({ count: -1 }, 'count', 'Counter');
|
||||||
|
expect(res).toBeInstanceOf(Error);
|
||||||
|
expect(logErrorAndReturnError).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [messages] = (logErrorAndReturnError as jest.Mock).mock.calls[0];
|
||||||
|
expect(Array.isArray(messages)).toBe(true);
|
||||||
|
expect(messages[0]).toBe(
|
||||||
|
'Invalid prop `count` of type `number` supplied to `Counter`, expected `positive integer or zero` (-1).'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns Error for non-integer numbers (e.g., float)', () => {
|
||||||
|
const res = PositiveIntegerOrZero({ count: 1.5 }, 'count', 'Widget');
|
||||||
|
expect(res).toBeInstanceOf(Error);
|
||||||
|
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
|
||||||
|
'Invalid prop `count` of type `number` supplied to `Widget`, expected `positive integer or zero` (1.5).'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Uses "N/A" component label when comp is falsy', () => {
|
||||||
|
const res = PositiveIntegerOrZero({ count: -2 }, 'count', '');
|
||||||
|
expect(res).toBeInstanceOf(Error);
|
||||||
|
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
|
||||||
|
'Invalid prop `count` of type `number` supplied to `N/A`, expected `positive integer or zero` (-2).'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PositiveInteger', () => {
|
||||||
|
test('Returns null when property is undefined', () => {
|
||||||
|
const obj = {};
|
||||||
|
const res = PositiveInteger(obj, 'age', 'Person');
|
||||||
|
expect(res).toBeNull();
|
||||||
|
expect(logErrorAndReturnError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns null for positive integers (excluding zero)', () => {
|
||||||
|
const cases = [1, 2, 100];
|
||||||
|
for (const val of cases) {
|
||||||
|
const res = PositiveInteger({ age: val }, 'age', 'Person');
|
||||||
|
expect(res).toBeNull();
|
||||||
|
}
|
||||||
|
expect(logErrorAndReturnError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns Error for zero', () => {
|
||||||
|
const res = PositiveInteger({ age: 0 }, 'age', 'Person');
|
||||||
|
expect(res).toBeInstanceOf(Error);
|
||||||
|
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toContain(
|
||||||
|
'Invalid prop `age` of type `number` supplied to `Person`, expected `positive integer` (0).'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns Error for negative numbers', () => {
|
||||||
|
const res = PositiveInteger({ age: -3 }, 'age', 'Person');
|
||||||
|
expect(res).toBeInstanceOf(Error);
|
||||||
|
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
|
||||||
|
'Invalid prop `age` of type `number` supplied to `Person`, expected `positive integer` (-3).'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns Error for non-integer numbers', () => {
|
||||||
|
const res = PositiveInteger({ age: 2.7 }, 'age', 'Person');
|
||||||
|
expect(res).toBeInstanceOf(Error);
|
||||||
|
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
|
||||||
|
'Invalid prop `age` of type `number` supplied to `Person`, expected `positive integer` (2.7).'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Uses "N/A" component label when comp is falsy', () => {
|
||||||
|
const res = PositiveInteger({ age: -1 }, 'age', '');
|
||||||
|
expect(res).toBeInstanceOf(Error);
|
||||||
|
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
|
||||||
|
'Invalid prop `age` of type `number` supplied to `N/A`, expected `positive integer` (-1).'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
33
frontend/tests/utils/helpers/publishedOnDate.test.ts
Normal file
33
frontend/tests/utils/helpers/publishedOnDate.test.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import publishedOnDate from '../../../src/static/js/utils/helpers/publishedOnDate';
|
||||||
|
|
||||||
|
// Helper to create Date in UTC to avoid timezone issues in CI environments
|
||||||
|
const makeDate = (y: number, mZeroBased: number, d: number) => new Date(Date.UTC(y, mZeroBased, d));
|
||||||
|
|
||||||
|
describe('js/utils/helpers', () => {
|
||||||
|
describe('publishedOnDate', () => {
|
||||||
|
test('Returns null when input is not a Date instance', () => {
|
||||||
|
expect(publishedOnDate(null as unknown as Date)).toBeNull();
|
||||||
|
expect(publishedOnDate(undefined as unknown as Date)).toBeNull();
|
||||||
|
expect(publishedOnDate('2020-01-02' as any as Date)).toBeNull();
|
||||||
|
expect(publishedOnDate(1577923200000 as unknown as Date)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Type 1 (default): "Mon DD, YYYY" with 3-letter month prefix before day', () => {
|
||||||
|
expect(publishedOnDate(makeDate(2020, 0, 2))).toBe('Jan 2, 2020');
|
||||||
|
expect(publishedOnDate(makeDate(1999, 11, 31))).toBe('Dec 31, 1999');
|
||||||
|
expect(publishedOnDate(makeDate(2024, 1, 29))).toBe('Feb 29, 2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Type 2: "DD Mon YYYY" with 3-letter month suffix', () => {
|
||||||
|
expect(publishedOnDate(makeDate(2020, 0, 2), 2)).toBe('2 Jan 2020');
|
||||||
|
expect(publishedOnDate(makeDate(1999, 11, 31), 2)).toBe('31 Dec 1999');
|
||||||
|
expect(publishedOnDate(makeDate(2024, 1, 29), 2)).toBe('29 Feb 2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Type 3: "DD Month YYYY" with full month name', () => {
|
||||||
|
expect(publishedOnDate(makeDate(2020, 0, 2), 3)).toBe('2 January 2020');
|
||||||
|
expect(publishedOnDate(makeDate(1999, 11, 31), 3)).toBe('31 December 1999');
|
||||||
|
expect(publishedOnDate(makeDate(2024, 1, 29), 3)).toBe('29 February 2024');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
45
frontend/tests/utils/helpers/quickSort.test.ts
Normal file
45
frontend/tests/utils/helpers/quickSort.test.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { quickSort } from '../../../src/static/js/utils/helpers/quickSort';
|
||||||
|
|
||||||
|
describe('js/utils/helpers', () => {
|
||||||
|
describe('quickSort', () => {
|
||||||
|
test('Returns the same array reference (in-place) and sorts ascending', () => {
|
||||||
|
const arr = [3, 1, 4, 1, 5, 9, 2];
|
||||||
|
const out = quickSort(arr, 0, arr.length - 1);
|
||||||
|
expect(out).toBe(arr);
|
||||||
|
expect(arr).toEqual([1, 1, 2, 3, 4, 5, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Handles already sorted arrays', () => {
|
||||||
|
const arr = [1, 2, 3, 4, 5];
|
||||||
|
quickSort(arr, 0, arr.length - 1);
|
||||||
|
expect(arr).toEqual([1, 2, 3, 4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Handles arrays with duplicates and negative numbers', () => {
|
||||||
|
const arr = [0, -1, -1, 2, 2, 1, 0];
|
||||||
|
quickSort(arr, 0, arr.length - 1);
|
||||||
|
expect(arr).toEqual([-1, -1, 0, 0, 1, 2, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Handles single-element array', () => {
|
||||||
|
const single = [42];
|
||||||
|
quickSort(single, 0, single.length - 1);
|
||||||
|
expect(single).toEqual([42]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Handles empty range without changes', () => {
|
||||||
|
const arr = [5, 4, 3];
|
||||||
|
// call with left > right (empty range)
|
||||||
|
quickSort(arr, 2, 1);
|
||||||
|
expect(arr).toEqual([5, 4, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Sorts subrange correctly without touching elements outside range', () => {
|
||||||
|
const arr = [9, 7, 5, 3, 1, 2, 4, 8, 6];
|
||||||
|
// sort only the middle [2..6]
|
||||||
|
quickSort(arr, 2, 6);
|
||||||
|
// The subrange [5,3,1,2,4] becomes [1,2,3,4,5]
|
||||||
|
expect(arr).toEqual([9, 7, 1, 2, 3, 4, 5, 8, 6]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
68
frontend/tests/utils/helpers/replacementStrings.test.ts
Normal file
68
frontend/tests/utils/helpers/replacementStrings.test.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { replaceString } from '../../../src/static/js/utils/helpers/replacementStrings';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
REPLACEMENTS?: Record<string, string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('js/utils/helpers', () => {
|
||||||
|
describe('replacementStrings', () => {
|
||||||
|
describe('replaceString', () => {
|
||||||
|
const originalReplacements = window.REPLACEMENTS;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete window.REPLACEMENTS;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.REPLACEMENTS = originalReplacements;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns the original word when window.REPLACEMENTS is undefined', () => {
|
||||||
|
delete window.REPLACEMENTS;
|
||||||
|
const input = 'Hello World';
|
||||||
|
const output = replaceString(input);
|
||||||
|
expect(output).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Replaces a single occurrence based on window.REPLACEMENTS map', () => {
|
||||||
|
window.REPLACEMENTS = { Hello: 'Hi' };
|
||||||
|
const output = replaceString('Hello World');
|
||||||
|
expect(output).toBe('Hi World');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Replaces multiple occurrences of the same key', () => {
|
||||||
|
window.REPLACEMENTS = { foo: 'bar' };
|
||||||
|
const output = replaceString('foo foo baz foo');
|
||||||
|
expect(output).toBe('bar bar baz bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Applies all entries in window.REPLACEMENTS (sequential split/join)', () => {
|
||||||
|
window.REPLACEMENTS = { a: 'A', A: 'X' };
|
||||||
|
// First replaces 'a'->'A' and then 'A'->'X'
|
||||||
|
const output = replaceString('aAaa');
|
||||||
|
expect(output).toBe('XXXX');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Supports empty string replacements (deletion)', () => {
|
||||||
|
window.REPLACEMENTS = { remove: '' };
|
||||||
|
const output = replaceString('please remove this');
|
||||||
|
expect(output).toBe('please this');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Handles overlapping keys by iteration order', () => {
|
||||||
|
window.REPLACEMENTS = { ab: 'X', b: 'Y' };
|
||||||
|
// First replaces 'ab' -> 'X', leaving no 'b' from that sequence, then replace standalone 'b' -> 'Y'
|
||||||
|
const output = replaceString('zab+b');
|
||||||
|
expect(output).toBe('zX+Y');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Works with special regex characters since split/join is literal', () => {
|
||||||
|
window.REPLACEMENTS = { '.': 'DOT', '*': 'STAR', '[]': 'BRACKETS' };
|
||||||
|
const output = replaceString('a.*b[]c.');
|
||||||
|
expect(output).toBe('aDOTSTARbBRACKETScDOT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user