Compare commits

..

17 Commits

Author SHA1 Message Date
Yiannis
df4b0422d5 fix(version): bump VERSION to 7.9 after accidental downgrade 2026-03-08 02:31:25 +02:00
Yiannis
0434f24691 chore(frontend): update frontend/src/static (generated by make build-frontend) 2026-03-08 02:23:26 +02:00
Yiannis
c2043fafa1 feat: utils/hooks unit tests 2026-02-07 18:39:24 +02:00
Yiannis
9f9dd699b2 feat: utils/stores unit tests 2026-02-07 18:09:46 +02:00
Yiannis
e2bc9399b9 feat: utils/classes unit tests 2026-02-07 18:09:46 +02:00
Yiannis
45d94069b9 feat: utils/actions unit tests 2026-02-07 18:09:46 +02:00
semantic-release-bot
b7427869b6 chore(release): 7.6.0 [skip ci]
## [7.6.0](https://github.com/mediacms-io/mediacms/compare/v7.5.0...v7.6.0) (2026-02-07)

### Features

* Create SECURITY.md ([#1485](https://github.com/mediacms-io/mediacms/issues/1485)) ([11449c2](11449c2187))
2026-02-07 10:31:40 +00:00
LabPixel
11449c2187 feat: Create SECURITY.md (#1485) 2026-02-07 12:31:10 +02:00
semantic-release-bot
f7c675596f chore(release): 7.5.0 [skip ci]
## [7.5.0](https://github.com/mediacms-io/mediacms/compare/v7.4.0...v7.5.0) (2026-02-06)

### Features

* bump version ([36d815c](36d815c0cf))
2026-02-06 17:26:12 +00:00
Markos Gogoulos
36d815c0cf feat: bump version 2026-02-06 19:25:31 +02:00
semantic-release-bot
8f28b00a63 chore(release): 7.4.0 [skip ci]
## [7.4.0](https://github.com/mediacms-io/mediacms/compare/v7.3.0...v7.4.0) (2026-02-06)

### Features

* Add video player context menu with share/embed options ([#1472](https://github.com/mediacms-io/mediacms/issues/1472)) ([74952f6](74952f68d7))
2026-02-06 17:24:27 +00:00
Yiannis Christodoulou
74952f68d7 feat: Add video player context menu with share/embed options (#1472) 2026-02-06 19:23:51 +02:00
semantic-release-bot
7950a4655a chore(release): 7.3.0 [skip ci]
## [7.3.0](https://github.com/mediacms-io/mediacms/compare/v7.2.0...v7.3.0) (2026-02-06)

### Features

* add package json for semantic release ([b405a04](b405a04e34))
* add semantic release github actions ([76a27ae](76a27ae256))
* frontend unit tests ([1c15880](1c15880ae3))
* Implement persistent "Embed Mode" to hide UI shell via Session Storage ([#1484](https://github.com/mediacms-io/mediacms/issues/1484)) ([223e870](223e87073f))
* Improve Visual Distinction Between Trim and Chapters Editors ([#1445](https://github.com/mediacms-io/mediacms/issues/1445)) ([d9b1d6c](d9b1d6cab1))
* semantic release ([b76282f](b76282f9e4))

### Bug Fixes

* add delay to task creation ([1b3cdfd](1b3cdfd302))
* Add regex denoter and improve celerybeat gitignore ([#1446](https://github.com/mediacms-io/mediacms/issues/1446)) ([90331f3](90331f3b4a))
* adjust poster url for audio ([01912ea](01912ea1f9))
* Chapter numbering and preserve custom titles on segment reorder ([#1435](https://github.com/mediacms-io/mediacms/issues/1435)) ([cd7dd4f](cd7dd4f72c))
* Show default chapter names in textarea instead of placeholder text ([#1428](https://github.com/mediacms-io/mediacms/issues/1428)) ([5eb6faf](5eb6fafb8c))
* static files ([#1429](https://github.com/mediacms-io/mediacms/issues/1429)) ([ba2c31b](ba2c31b1e6))

### Documentation

* update page link ([aeef828](aeef8284bf))
2026-02-06 16:56:49 +00:00
Markos Gogoulos
b76282f9e4 feat: semantic release 2026-02-06 18:56:13 +02:00
Markos Gogoulos
b405a04e34 feat: add package json for semantic release 2026-02-06 18:53:03 +02:00
Markos Gogoulos
76a27ae256 feat: add semantic release github actions 2026-02-06 18:40:50 +02:00
Markos Gogoulos
223e87073f feat: Implement persistent "Embed Mode" to hide UI shell via Session Storage (#1484)
* initial implementation

* updates in ViewerInfoVideoTitleBanner component

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

---------

Co-authored-by: Yiannis <1515939+styiannis@users.noreply.github.com>
2026-01-31 15:27:40 +02:00
187 changed files with 21286 additions and 11461 deletions

View File

@@ -0,0 +1,22 @@
name: "Lint PR"
on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
permissions:
pull-requests: read
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
environment: dev
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

47
.github/workflows/semantic-release.yaml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Semantic Release
on:
push:
branches:
- main
permissions:
contents: write
issues: write
jobs:
semantic-release:
runs-on: ubuntu-latest
environment: dev
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup SSH
uses: webfactory/ssh-agent@v0.8.0
with:
ssh-private-key: ${{ secrets.GA_DEPLOY_KEY }}
# use SSH url to ensure git commit using a deploy key bypasses the main
# branch protection rule
- name: Configure Git for SSH Push
run: git remote set-url origin "git@github.com:${{ github.repository }}.git"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install Dependencies
run: npm clean-install
- name: Verify the integrity of provenance attestations and registry signatures for installed dependencies
run: npm audit signatures
- name: Run Semantic Release
run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

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

100
.releaserc.json Normal file
View File

@@ -0,0 +1,100 @@
{
"branches": [
"main"
],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits",
"presetConfig": {
"types": [
{
"type": "feat",
"section": "Features"
},
{
"type": "fix",
"section": "Bug Fixes"
},
{
"type": "chore",
"hidden": true
},
{
"type": "docs",
"section": "Documentation"
},
{
"type": "style",
"hidden": true
},
{
"type": "refactor",
"section": "Refactors"
},
{
"type": "perf",
"section": "Performance"
},
{
"type": "test",
"hidden": true
},
{
"type": "depr",
"section": "Deprecations"
}
]
}
}
],
[
"semantic-release-replace-plugin",
{
"replacements": [
{
"files": [
"package.json"
],
"from": "\"version\": \".*\"",
"to": "\"version\": \"${nextRelease.version}\"",
"results": [
{
"file": "package.json",
"hasChanged": true,
"numMatches": 1,
"numReplacements": 1
}
],
"countMatches": true
}
]
}
],
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md",
"changelogTitle": "# Changelog"
}
],
"@semantic-release/github",
[
"@semantic-release/git",
{
"assets": [
"package.json",
"CHANGELOG.md"
],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
}

43
CHANGELOG.md Normal file
View File

@@ -0,0 +1,43 @@
# Changelog
## [7.6.0](https://github.com/mediacms-io/mediacms/compare/v7.5.0...v7.6.0) (2026-02-07)
### Features
* Create SECURITY.md ([#1485](https://github.com/mediacms-io/mediacms/issues/1485)) ([11449c2](https://github.com/mediacms-io/mediacms/commit/11449c2187d0f450b86915d88f92595a1825e4cf))
## [7.5.0](https://github.com/mediacms-io/mediacms/compare/v7.4.0...v7.5.0) (2026-02-06)
### Features
* bump version ([36d815c](https://github.com/mediacms-io/mediacms/commit/36d815c0cfbe21d3136541d410d545742b9ebecd))
## [7.4.0](https://github.com/mediacms-io/mediacms/compare/v7.3.0...v7.4.0) (2026-02-06)
### Features
* Add video player context menu with share/embed options ([#1472](https://github.com/mediacms-io/mediacms/issues/1472)) ([74952f6](https://github.com/mediacms-io/mediacms/commit/74952f68d79bc67617edb38eac62d2f5e7457565))
## [7.3.0](https://github.com/mediacms-io/mediacms/compare/v7.2.0...v7.3.0) (2026-02-06)
### Features
* add package json for semantic release ([b405a04](https://github.com/mediacms-io/mediacms/commit/b405a04e346ca81b7d3f4e099eb984e7785cdd0f))
* add semantic release github actions ([76a27ae](https://github.com/mediacms-io/mediacms/commit/76a27ae25609178c1bd47c947b9f1a082c791d61))
* frontend unit tests ([1c15880](https://github.com/mediacms-io/mediacms/commit/1c15880ae3ef1ce77f53d5b473dfc0cc448b4977))
* Implement persistent "Embed Mode" to hide UI shell via Session Storage ([#1484](https://github.com/mediacms-io/mediacms/issues/1484)) ([223e870](https://github.com/mediacms-io/mediacms/commit/223e87073f7d5e44130c9976854cac670db0ae66))
* Improve Visual Distinction Between Trim and Chapters Editors ([#1445](https://github.com/mediacms-io/mediacms/issues/1445)) ([d9b1d6c](https://github.com/mediacms-io/mediacms/commit/d9b1d6cab1d2bdfc16f799a0a27b64313e2e0d22))
* semantic release ([b76282f](https://github.com/mediacms-io/mediacms/commit/b76282f9e465a39c2da5e9a22184d1db23de3f56))
### Bug Fixes
* add delay to task creation ([1b3cdfd](https://github.com/mediacms-io/mediacms/commit/1b3cdfd302abc5e69ebe01ca52b5091f3b24c0b2))
* Add regex denoter and improve celerybeat gitignore ([#1446](https://github.com/mediacms-io/mediacms/issues/1446)) ([90331f3](https://github.com/mediacms-io/mediacms/commit/90331f3b4a2a5737de9dd75ab45c096944813c42))
* adjust poster url for audio ([01912ea](https://github.com/mediacms-io/mediacms/commit/01912ea1f99ef43793a65712539d6264f1f6410f))
* Chapter numbering and preserve custom titles on segment reorder ([#1435](https://github.com/mediacms-io/mediacms/issues/1435)) ([cd7dd4f](https://github.com/mediacms-io/mediacms/commit/cd7dd4f72c9f0bac466c680f686a9ecfdd3a38dd))
* Show default chapter names in textarea instead of placeholder text ([#1428](https://github.com/mediacms-io/mediacms/issues/1428)) ([5eb6faf](https://github.com/mediacms-io/mediacms/commit/5eb6fafb8c6928b8bc3fe5f0c7af315273f78a55))
* static files ([#1429](https://github.com/mediacms-io/mediacms/issues/1429)) ([ba2c31b](https://github.com/mediacms-io/mediacms/commit/ba2c31b1e65b7f508dee598b1f2d86f01f9bf036))
### Documentation
* update page link ([aeef828](https://github.com/mediacms-io/mediacms/commit/aeef8284bfba2a9a7f69c684f96c54f0e0e0cf92))

23
HISTORY.md Normal file
View File

@@ -0,0 +1,23 @@
# History
## 3.0.0
### Features
- Updates Python/Django requirements and Dockerfile to use latest 3.11 Python - https://github.com/mediacms-io/mediacms/pull/826/files. This update requires some manual steps, for existing (not new) installations. Check the update section under the [Admin docs](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#2-server-installation), either for single server or for Docker Compose installations
- Upgrade postgres on Docker Compose - https://github.com/mediacms-io/mediacms/pull/749
### Fixes
- video player options for HLS - https://github.com/mediacms-io/mediacms/pull/832
- AVI videos not correctly recognised as videos - https://github.com/mediacms-io/mediacms/pull/833
## 2.1.0
### Fixes
- Increase uwsgi buffer-size parameter. This prevents an error by uwsgi with large headers - [#5b60](https://github.com/mediacms-io/mediacms/commit/5b601698a41ad97f08c1830e14b1c18f73ab8315)
- Fix issues with comments. These were not reported on the tracker but it is certain that they would not show comments on media files (non videos but also videos). Unfortunately this reverts work done with Timestamps on comments + Mentions on comments, more on PR [#802](https://github.com/mediacms-io/mediacms/pull/802)
### Features
- Allow tags to contains other characters too, not only English alphabet ones [#801](https://github.com/mediacms-io/mediacms/pull/801)
- Add simple cookie consent code [#799](https://github.com/mediacms-io/mediacms/pull/799)
- Allow password reset & email verify pages on global login required [#790](https://github.com/mediacms-io/mediacms/pull/790)
- Add api_url field to search api [#692](https://github.com/mediacms-io/mediacms/pull/692)

View File

@@ -1,253 +0,0 @@
# MediaCMS LTI 1.3 Integration Setup Guide
This guide walks you through integrating MediaCMS with a Learning Management System (LMS) like Moodle using LTI 1.3.
## 1. Configure MediaCMS Settings
Add these settings to `cms/local_settings.py`:
```python
# Enable LTI integration
USE_LTI = True
# Enable RBAC for course-based access control
USE_RBAC = True
# Your production domain
FRONTEND_HOST = 'https://your-mediacms-domain.com'
ALLOWED_HOSTS = ['your-mediacms-domain.com', 'localhost']
```
**Note:** LTI-specific cookie settings (SESSION_COOKIE_SAMESITE='None', etc.) are automatically applied when `USE_LTI=True`.
## 2. MediaCMS Configuration
### A. Verify HTTPS Setup
Ensure your MediaCMS server is running on HTTPS. LTI 1.3 requires HTTPS for security and iframe embedding.
### B. Register Your LMS Platform
1. Access Django Admin: `https://your-mediacms-domain.com/admin/lti/ltiplatform/`
2. Add new LTI Platform with these settings:
**Basic Info:**
- **Name:** My LMS (or any descriptive name)
- **Platform ID (Issuer):** Get this from your LMS (e.g., `https://mylms.example.com`)
- **Client ID:** You'll get this from your LMS after registering MediaCMS as an external tool
**OIDC Endpoints (get from your LMS):**
- **Auth Login URL:** `https://mylms.example.com/mod/lti/auth.php`
- **Auth Token URL:** `https://mylms.example.com/mod/lti/token.php`
- **Key Set URL:** `https://mylms.example.com/mod/lti/certs.php`
**Deployment IDs:** Add the deployment ID(s) provided by your LMS as a JSON list, e.g., `["1"]`
**Features:**
- ✓ Enable NRPS (Names and Role Provisioning)
- ✓ Enable Deep Linking
- ✓ Auto-create categories
- ✓ Auto-create users
- ✓ Auto-sync roles
### C. Note MediaCMS URLs for LMS Configuration
You'll need these URLs when configuring your LMS:
- **Tool URL:** `https://your-mediacms-domain.com/lti/launch/`
- **OIDC Login URL:** `https://your-mediacms-domain.com/lti/oidc/login/`
- **JWK Set URL:** `https://your-mediacms-domain.com/lti/jwks/`
- **Redirection URI:** `https://your-mediacms-domain.com/lti/launch/`
- **Deep Linking URL:** `https://your-mediacms-domain.com/lti/select-media/`
## 3. LMS Configuration (Moodle Example)
### A. Register MediaCMS as External Tool
1. Navigate to: **Site administration → Plugins → Activity modules → External tool → Manage tools**
2. Click **Configure a tool manually** or add new tool
**Basic Settings:**
- **Tool name:** MediaCMS
- **Tool URL:** `https://your-mediacms-domain.com/lti/launch/`
- **LTI version:** LTI 1.3
- **Tool configuration usage:** Show in activity chooser
**URLs:**
- **Public keyset URL:** `https://your-mediacms-domain.com/lti/jwks/`
- **Initiate login URL:** `https://your-mediacms-domain.com/lti/oidc/login/`
- **Redirection URI(s):** `https://your-mediacms-domain.com/lti/launch/`
**Launch Settings:**
- **Default launch container:** Embed (without blocks) or New window
- **Accept grades from tool:** Optional
- **Share launcher's name:** Always ⚠️ **REQUIRED for user names**
- **Share launcher's email:** Always ⚠️ **REQUIRED for user emails**
> **Important:** MediaCMS creates user accounts automatically on first LTI launch. To ensure users have proper names and email addresses in MediaCMS, you **must** set both "Share launcher's name with tool" and "Share launcher's email with tool" to **Always** in the Privacy settings. Without these settings, users will be created with only a username based on their LTI user ID.
**Services:**
- ✓ IMS LTI Names and Role Provisioning (for roster sync)
- ✓ IMS LTI Deep Linking (for media selection)
**Tool Settings (Important for Deep Linking):**
-**Supports Deep Linking (Content-Item Message)** - Enable this to allow instructors to browse and select media from MediaCMS when adding activities
3. Save the tool configuration
### B. Copy Platform Details to MediaCMS
After saving, your LMS will provide:
- Platform ID (Issuer URL)
- Client ID
- Deployment ID
Copy these values back to the LTIPlatform configuration in MediaCMS admin (step 2B above).
### C. Using MediaCMS in Courses
**Option 1: Embed "My Media" view (Default)**
- In a course, add activity → External tool → MediaCMS
- Leave the custom URL blank (uses default launch URL)
- Students/teachers will see their MediaCMS profile in an iframe
**Option 2: Link to a Specific Video**
- Add activity → External tool → MediaCMS
- Activity name: "November 2020 Video" (or any descriptive name)
- In the activity settings, find **"Custom parameters"** (may be under "Privacy" or "Additional Settings")
- Add this parameter:
```
media_friendly_token=abc123def
```
- Replace `abc123def` with your video's token from MediaCMS (found in the URL: `/view?m=abc123def`)
- Students clicking this activity will go directly to that specific video
**Option 3: Link to Any MediaCMS Page**
- Add activity → External tool → MediaCMS
- In **"Custom parameters"**, add:
```
redirect_path=/featured
```
- Supported paths:
- `/featured` - Featured videos page
- `/latest` - Latest videos
- `/search/?q=keyword` - Search results
- `/category/category-name` - Specific category
- `/user/username` - User's profile
- Any other MediaCMS page path
**Option 4: Embed Specific Media via Deep Linking (Interactive)**
⚠️ **Prerequisite:** Ensure "Supports Deep Linking (Content-Item Message)" is enabled in the External Tool configuration (see section 3.A above)
When adding the activity to your course:
1. Add activity → External tool → MediaCMS
2. In the activity settings, enable **"Supports Deep Linking"** checkbox (may be under "Tool settings" or "Privacy" section)
3. Click **"Select content"** button → This launches the MediaCMS media browser
4. Browse and select media from MediaCMS (you can select multiple)
5. Click **"Add to course"** → Returns to Moodle with selected media configured
6. The activity will be automatically configured with the selected media's title and embed URL
7. Students clicking this activity will go directly to the selected media
### D. Custom Parameters - Complete Examples
**Example 1: Link to a specific video titled "Lecture 1 - Introduction"**
```
Activity Name: Lecture 1 - Introduction
Custom Parameters:
media_friendly_token=a1b2c3d4e5
```
**Example 2: Link to course-specific videos**
```
Activity Name: Course Videos
Custom Parameters:
redirect_path=/category/biology101
```
**Example 3: Link to search results for "genetics"**
```
Activity Name: Genetics Videos
Custom Parameters:
redirect_path=/search/?q=genetics
```
**Example 4: Link to featured content**
```
Activity Name: Featured Videos
Custom Parameters:
redirect_path=/featured
```
**Where to find Custom Parameters in Moodle:**
1. When creating/editing the External Tool activity
2. Expand **"Privacy"** section, or look for **"Additional Settings"**
3. Find the **"Custom parameters"** text field
4. Enter one parameter per line in the format: `key=value`
## 4. Testing Checklist
- [ ] HTTPS is working on MediaCMS
- [ ] `USE_LTI = True` in local_settings.py
- [ ] LTIPlatform configured in Django admin
- [ ] External tool registered in LMS
- [ ] Launch from LMS creates new user in MediaCMS
- [ ] Course is mapped to MediaCMS category
- [ ] Users are added to RBAC group with correct roles
- [ ] Media from course category is visible to course members
- [ ] Public media is accessible
- [ ] Private media from other courses is not accessible
## 5. Default Role Mappings
The system automatically maps LMS roles to MediaCMS:
- **Instructor/Teacher** → advancedUser (global) + manager (course group)
- **Student/Learner** → user (global) + member (course group)
- **Teaching Assistant** → user (global) + contributor (course group)
- **Administrator** → manager (global) + manager (course group)
You can customize these in Django admin under **LTI Role Mappings**.
## 6. User Creation and Authentication
### User Creation via LTI
When a user launches MediaCMS from your LMS for the first time, a MediaCMS account is automatically created with:
- **Username:** Generated from email (preferred) or name, or a unique ID if neither is available
- **Email:** From LTI claim (if shared by LMS)
- **Name:** From LTI given_name/family_name claims (if shared by LMS)
- **Roles:** Mapped from LTI roles to MediaCMS permissions
- **Course membership:** Automatically added to the RBAC group for the course
### Privacy Settings Are Critical
⚠️ **For proper user accounts, you must configure the LTI tool's privacy settings in Moodle:**
1. Edit the External Tool configuration in Moodle
2. Go to the **Privacy** section
3. Set **"Share launcher's name with tool"** to **Always**
4. Set **"Share launcher's email with tool"** to **Always**
Without these settings:
- Users will not have proper names in MediaCMS
- Users will not have email addresses
- Usernames will be generic hashes (e.g., `lti_user_abc123def`)
### Authentication
Users created through LTI integration do **not** have a password set. They can only access MediaCMS through LTI launches from your LMS. This is intentional for security.
If you need a user to have both LTI access and direct login capability, manually set a password using:
```bash
python manage.py changepassword <username>
```
## Need Help?
If you encounter issues, check:
- `/admin/lti/ltilaunchlog/` for launch attempt logs
- Django logs for detailed error messages
- Ensure HTTPS is properly configured (required for iframe cookies)
- Verify all URLs are correct and accessible
- Check that the Client ID and Deployment ID match between MediaCMS and your LMS

View File

@@ -108,7 +108,7 @@ There are two ways to run MediaCMS, through Docker Compose and through installin
## Technology
This software uses the following list of awesome technologies: Python, Django, Django Rest Framework, Celery, PostgreSQL, Redis, Nginx, Gunicorn, React, Fine Uploader, video.js, FFMPEG, Bento4
This software uses the following list of awesome technologies: Python, Django, Django Rest Framework, Celery, PostgreSQL, Redis, Nginx, uWSGI, React, Fine Uploader, video.js, FFMPEG, Bento4
## Who is using it

54
SECURITY.md Normal file
View File

@@ -0,0 +1,54 @@
# Security Policy
Thank you for helping improve the security of MediaCMS.
We take security vulnerabilities seriously and appreciate responsible disclosure.
---
## Reporting a Vulnerability
If you discover a security vulnerability in MediaCMS, **please do not open a public GitHub issue**.
Instead, report it using one of the following methods:
- **GitHub Security Advisories (preferred)**
Use the "Report a vulnerability" feature in this repository.
- **Contact Form**
Submit details via the official contact page:
https://mediacms.io/contact/
Please include as much of the following information as possible:
- Affected version(s)
- Detailed description of the issue
- Steps to reproduce (PoC if available)
- Impact assessment (e.g. RCE, XSS, privilege escalation)
- Any potential mitigations you are aware of
---
## Supported Versions
Security updates are provided for the **latest stable release** of MediaCMS.
Older versions may not receive security patches.
---
## Disclosure Policy
- We aim to acknowledge reports within **7 days**
- We aim to provide a fix or mitigation within **90 days**, depending on severity
- Please allow us time to investigate before any public disclosure
We follow responsible disclosure practices and will coordinate disclosure timelines when appropriate.
---
## Recognition
At this time, MediaCMS does not operate a formal bug bounty program.
However, we are happy to acknowledge valid security reports in release notes or advisories (with your permission).
---
Thank you for helping keep MediaCMS secure.

View File

@@ -24,7 +24,6 @@ INSTALLED_APPS = [
"actions.apps.ActionsConfig",
"rbac.apps.RbacConfig",
"identity_providers.apps.IdentityProvidersConfig",
"lti.apps.LtiConfig",
"debug_toolbar",
"mptt",
"crispy_forms",

View File

@@ -300,7 +300,6 @@ INSTALLED_APPS = [
"actions.apps.ActionsConfig",
"rbac.apps.RbacConfig",
"identity_providers.apps.IdentityProvidersConfig",
"lti.apps.LtiConfig",
"debug_toolbar",
"mptt",
"crispy_forms",
@@ -556,7 +555,6 @@ DJANGO_ADMIN_URL = "admin/"
USE_SAML = False
USE_RBAC = False
USE_IDENTITY_PROVIDERS = False
USE_LTI = False # Enable LTI 1.3 integration
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
USE_ROUNDED_CORNERS = True
@@ -652,19 +650,3 @@ if USERS_NEEDS_TO_BE_APPROVED:
)
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware")
# LTI 1.3 Integration Settings
if USE_LTI:
# Session timeout for LTI launches (seconds)
LTI_SESSION_TIMEOUT = 3600 # 1 hour
# Cookie settings required for iframe embedding from LMS
# IMPORTANT: Requires HTTPS to be enabled
SESSION_COOKIE_SAMESITE = 'None'
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'None'
CSRF_COOKIE_SECURE = True
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
# Use cached_db for reliability - stores in both cache AND database
# This prevents session loss during multiple simultaneous LTI launches

View File

@@ -25,7 +25,6 @@ urlpatterns = [
re_path(r"^", include("files.urls")),
re_path(r"^", include("users.urls")),
re_path(r"^accounts/", include("allauth.urls")),
re_path(r"^lti/", include("lti.urls")),
re_path(r"^api-auth/", include("rest_framework.urls")),
path(settings.DJANGO_ADMIN_URL, admin.site.urls),
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),

View File

@@ -1 +1 @@
VERSION = "7.8124"
VERSION = "7.9"

View File

@@ -1,9 +1,3 @@
# Use existing X-Forwarded-Proto from reverse proxy if present, otherwise use $scheme
map $http_x_forwarded_proto $forwarded_proto {
default $http_x_forwarded_proto;
'' $scheme;
}
server {
listen 80 ;
@@ -34,10 +28,7 @@ server {
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
include /etc/nginx/sites-enabled/uwsgi_params;
uwsgi_pass 127.0.0.1:9000;
}
}

View File

@@ -37,6 +37,7 @@ fi
cp deploy/docker/nginx_http_only.conf /etc/nginx/sites-available/default
cp deploy/docker/nginx_http_only.conf /etc/nginx/sites-enabled/default
cp deploy/docker/uwsgi_params /etc/nginx/sites-enabled/uwsgi_params
cp deploy/docker/nginx.conf /etc/nginx/
#### Supervisord Configurations #####
@@ -44,12 +45,12 @@ cp deploy/docker/nginx.conf /etc/nginx/
cp deploy/docker/supervisord/supervisord-debian.conf /etc/supervisor/conf.d/supervisord-debian.conf
if [ X"$ENABLE_UWSGI" = X"yes" ] ; then
echo "Enabling gunicorn app server"
cp deploy/docker/supervisord/supervisord-gunicorn.conf /etc/supervisor/conf.d/supervisord-gunicorn.conf
echo "Enabling uwsgi app server"
cp deploy/docker/supervisord/supervisord-uwsgi.conf /etc/supervisor/conf.d/supervisord-uwsgi.conf
fi
if [ X"$ENABLE_NGINX" = X"yes" ] ; then
echo "Enabling nginx as gunicorn app proxy and media server"
echo "Enabling nginx as uwsgi app proxy and media server"
cp deploy/docker/supervisord/supervisord-nginx.conf /etc/supervisor/conf.d/supervisord-nginx.conf
fi

View File

@@ -11,7 +11,7 @@ else
echo "There is no script $PRE_START_PATH"
fi
# Start Supervisor, with Nginx and Gunicorn
# Start Supervisor, with Nginx and uWSGI
echo "Starting server using supervisord..."
exec /usr/bin/supervisord

View File

@@ -1,9 +0,0 @@
[program:gunicorn]
command=/home/mediacms.io/bin/gunicorn cms.wsgi:application --workers=2 --threads=2 --worker-class=gthread --bind=127.0.0.1:9000 --user=www-data --group=www-data --timeout=120 --keep-alive=5 --max-requests=1000 --max-requests-jitter=50 --access-logfile=- --error-logfile=- --log-level=info --chdir=/home/mediacms.io/mediacms
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=100
startinorder=true
startsecs=0

View File

@@ -0,0 +1,9 @@
[program:uwsgi]
command=/home/mediacms.io/bin/uwsgi --ini /home/mediacms.io/mediacms/deploy/docker/uwsgi.ini
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=100
startinorder=true
startsecs=0

24
deploy/docker/uwsgi.ini Normal file
View File

@@ -0,0 +1,24 @@
[uwsgi]
chdir = /home/mediacms.io/mediacms/
virtualenv = /home/mediacms.io
module = cms.wsgi
uid=www-data
gid=www-data
processes = 2
threads = 2
master = true
socket = 127.0.0.1:9000
workers = 2
vacuum = true
hook-master-start = unix_signal:15 gracefully_kill_them_all
need-app = true
die-on-term = true
buffer-size=32768

View File

@@ -0,0 +1,16 @@
uwsgi_param QUERY_STRING $query_string;
uwsgi_param REQUEST_METHOD $request_method;
uwsgi_param CONTENT_TYPE $content_type;
uwsgi_param CONTENT_LENGTH $content_length;
uwsgi_param REQUEST_URI $request_uri;
uwsgi_param PATH_INFO $document_uri;
uwsgi_param DOCUMENT_ROOT $document_root;
uwsgi_param SERVER_PROTOCOL $server_protocol;
uwsgi_param REQUEST_SCHEME $scheme;
uwsgi_param HTTPS $https if_not_empty;
uwsgi_param REMOTE_ADDR $remote_addr;
uwsgi_param REMOTE_PORT $remote_port;
uwsgi_param SERVER_PORT $server_port;
uwsgi_param SERVER_NAME $server_name;

View File

@@ -0,0 +1,22 @@
[Unit]
Description=MediaCMS celery beat
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
Restart=always
RestartSec=10
WorkingDirectory=/home/mediacms.io/mediacms
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/beat%n.pid"
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/beat%N.log"
Environment=CELERYD_LOG_LEVEL="INFO"
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms beat --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL}'
ExecStop=/bin/kill -s TERM $MAINPID
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,29 @@
[Unit]
Description=MediaCMS celery long queue
After=network.target
[Service]
Type=forking
User=www-data
Group=www-data
Restart=always
RestartSec=10
WorkingDirectory=/home/mediacms.io/mediacms
Environment=CELERYD_NODES="long1"
Environment=CELERY_QUEUE="long_tasks"
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
Environment=CELERYD_MULTI="multi"
Environment=CELERYD_OPTS="-Ofair --prefetch-multiplier=1"
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/%n.pid"
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/%N.log"
Environment=CELERYD_LOG_LEVEL="INFO"
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms multi start ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
ExecStop=/bin/sh -c '${CELERY_BIN} -A cms multi stopwait ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE}'
ExecReload=/bin/sh -c '${CELERY_BIN} -A cms multi restart ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,39 @@
[Unit]
Description=MediaCMS celery short queue
After=network.target
[Service]
Type=forking
User=www-data
Group=www-data
Restart=always
RestartSec=10
WorkingDirectory=/home/mediacms.io/mediacms
Environment=CELERYD_NODES="short1 short2"
Environment=CELERY_QUEUE="short_tasks"
# Absolute or relative path to the 'celery' command:
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
# App instance to use
# comment out this line if you don't use an app
# or fully qualified:
#CELERY_APP="proj.tasks:app"
# How to call manage.py
Environment=CELERYD_MULTI="multi"
# Extra command-line arguments to the worker
Environment=CELERYD_OPTS="--soft-time-limit=300 -c10"
# - %n will be replaced with the first part of the nodename.
# - %I will be replaced with the current child process index
# and is important when using the prefork pool to avoid race conditions.
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/%n.pid"
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/%N.log"
Environment=CELERYD_LOG_LEVEL="INFO"
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms multi start ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
ExecStop=/bin/sh -c '${CELERY_BIN} -A cms multi stopwait ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE}'
ExecReload=/bin/sh -c '${CELERY_BIN} -A cms multi restart ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
-----BEGIN DH PARAMETERS-----
MIICCAKCAgEAo3MMiEY/fNbu+usIM0cDi6x8G3JBApv0Lswta4kiyedWT1WN51iQ
9zhOFpmcu6517f/fR9MUdyhVKHxxSqWQTcmTEFtz4P3VLTS/W1N5VbKE2VEMLpIi
wr350aGvV1Er0ujcp5n4O4h0I1tn4/fNyDe7+pHCdwM+hxe8hJ3T0/tKtad4fnIs
WHDjl4f7m7KuFfheiK7Efb8MsT64HDDAYXn+INjtDZrbE5XPw20BqyWkrf07FcPx
8o9GW50Ox7/FYq7jVMI/skEu0BRc8u6uUD9+UOuWUQpdeHeFcvLOgW53Z03XwWuX
RXosUKzBPuGtUDAaKD/HsGW6xmGr2W9yRmu27jKpfYLUb/eWbbnRJwCw04LdzPqv
jmtq02Gioo3lf5H5wYV9IYF6M8+q/slpbttsAcKERimD1273FBRt5VhSugkXWKjr
XDhoXu6vZgj8Opei38qPa8pI1RUFoXHFlCe6WpZQmU8efL8gAMrJr9jUIY8eea1n
u20t5B9ueb9JMjrNafcq6QkKhZLi6fRDDTUyeDvc0dN9R/3Yts97SXfdi1/lX7HS
Ht4zXd5hEkvjo8GcnjsfZpAC39QfHWkDaeUGEqsl3jXjVMfkvoVY51OuokPWZzrJ
M5+wyXNpfGbH67dPk7iHgN7VJvgX0SYscDPTtms50Vk7RwEzLeGuSHMCAQI=
-----END DH PARAMETERS-----

View File

@@ -0,0 +1,84 @@
server {
listen 80 ;
server_name localhost;
gzip on;
access_log /var/log/nginx/mediacms.io.access.log;
error_log /var/log/nginx/mediacms.io.error.log warn;
# # redirect to https if logged in
# if ($http_cookie ~* "sessionid") {
# rewrite ^/(.*)$ https://localhost/$1 permanent;
# }
# # redirect basic forms to https
# location ~ (login|login_form|register|mail_password_form)$ {
# rewrite ^/(.*)$ https://localhost/$1 permanent;
# }
location /static {
alias /home/mediacms.io/mediacms/static ;
}
location /media/original {
alias /home/mediacms.io/mediacms/media_files/original;
}
location /media {
alias /home/mediacms.io/mediacms/media_files ;
}
location / {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
include /etc/nginx/sites-enabled/uwsgi_params;
uwsgi_pass 127.0.0.1:9000;
}
}
server {
listen 443 ssl;
server_name localhost;
ssl_certificate_key /etc/letsencrypt/live/localhost/privkey.pem;
ssl_certificate /etc/letsencrypt/live/localhost/fullchain.pem;
ssl_dhparam /etc/nginx/dhparams/dhparams.pem;
ssl_protocols TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_ecdh_curve secp521r1:secp384r1;
ssl_prefer_server_ciphers on;
gzip on;
access_log /var/log/nginx/mediacms.io.access.log;
error_log /var/log/nginx/mediacms.io.error.log warn;
location /static {
alias /home/mediacms.io/mediacms/static ;
}
location /media/original {
alias /home/mediacms.io/mediacms/media_files/original;
#auth_basic "auth protected area";
#auth_basic_user_file /home/mediacms.io/mediacms/deploy/local_install/.htpasswd;
}
location /media {
alias /home/mediacms.io/mediacms/media_files ;
}
location / {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
include /etc/nginx/sites-enabled/uwsgi_params;
uwsgi_pass 127.0.0.1:9000;
}
}

View File

@@ -0,0 +1,58 @@
-----BEGIN CERTIFICATE-----
MIIFTjCCBDagAwIBAgISBNOUeDlerH9MkKmHLvZJeMYgMA0GCSqGSIb3DQEBCwUA
MEoxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSMwIQYDVQQD
ExpMZXQncyBFbmNyeXB0IEF1dGhvcml0eSBYMzAeFw0yMDAzMTAxNzUxNDFaFw0y
MDA2MDgxNzUxNDFaMBYxFDASBgNVBAMTC21lZGlhY21zLmlvMIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAps5Jn18nW2tq/LYFDgQ1YZGLlpF/B2AAPvvH
3yuD+AcT4skKdZouVL/a5pXrptuYL5lthO9dlcja2tuO2ltYrb7Dp01dAIFaJE8O
DKd+Sv5wr8VWQZykqzMiMBgviml7TBvUHQjvCJg8UwmnN0XSUILCttd6u4qOzS7d
lKMMsKpYzLhElBT0rzhhsWulDiy6aAZbMV95bfR74nIWsBJacy6jx3jvxAuvCtkB
OVdOoVL6BPjDE3SNEk53bAZGIb5A9ri0O5jh/zBFT6tQSjUhAUTkmv9oZP547RnV
fDj+rdvCVk/fE+Jno36mcT183Qd/Ty3fWuqFoM5g/luhnfvWEwIDAQABo4ICYDCC
AlwwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
AjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTd5EZBt74zu5XxT1uXQs6oM8qOuDAf
BgNVHSMEGDAWgBSoSmpjBH3duubRObemRWXv86jsoTBvBggrBgEFBQcBAQRjMGEw
LgYIKwYBBQUHMAGGImh0dHA6Ly9vY3NwLmludC14My5sZXRzZW5jcnlwdC5vcmcw
LwYIKwYBBQUHMAKGI2h0dHA6Ly9jZXJ0LmludC14My5sZXRzZW5jcnlwdC5vcmcv
MBYGA1UdEQQPMA2CC21lZGlhY21zLmlvMEwGA1UdIARFMEMwCAYGZ4EMAQIBMDcG
CysGAQQBgt8TAQEBMCgwJgYIKwYBBQUHAgEWGmh0dHA6Ly9jcHMubGV0c2VuY3J5
cHQub3JnMIIBBAYKKwYBBAHWeQIEAgSB9QSB8gDwAHYAXqdz+d9WwOe1Nkh90Eng
MnqRmgyEoRIShBh1loFxRVgAAAFwxcnL+AAABAMARzBFAiAb3yeBuW3j9MxcRc0T
icUBvEa/rH7Fv2eB0oQlnZ1exQIhAPf+CtTXmzxoeT/BBiivj4AmGDsq4xWhe/U6
BytYrKLeAHYAB7dcG+V9aP/xsMYdIxXHuuZXfFeUt2ruvGE6GmnTohwAAAFwxcnM
HAAABAMARzBFAiAuP5gKyyaT0LVXxwjYD9zhezvxf4Icx0P9pk75c5ao+AIhAK0+
fSJv+WTXciMT6gA1sk/tuCHuDFAuexSA/6TcRXcVMA0GCSqGSIb3DQEBCwUAA4IB
AQCPCYBU4Q/ro2MUkjDPKGmeqdxQycS4R9WvKTG/nmoahKNg30bnLaDPUcpyMU2k
sPDemdZ7uTGLZ3ZrlIva8DbrnJmrTPf9BMwaM6j+ZV/QhxvKZVIWkLkZrwiVI57X
Ba+rs5IEB4oWJ0EBaeIrzeKG5zLMkRcIdE4Hlhuwu3zGG56c+wmAPuvpIDlYoO6o
W22xRdxoTIHBvkzwonpVYUaRcaIw+48xnllxh1dHO+X69DT45wlF4tKveOUi+L50
4GWJ8Vjv7Fot/WNHEM4Mnmw0jHj9TPkIZKnPNRMdHmJ5CF/FJFDiptOeuzbfohG+
mdvuInb8JDc0XBE99Gf/S4/y
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEkjCCA3qgAwIBAgIQCgFBQgAAAVOFc2oLheynCDANBgkqhkiG9w0BAQsFADA/
MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
DkRTVCBSb290IENBIFgzMB4XDTE2MDMxNzE2NDA0NloXDTIxMDMxNzE2NDA0Nlow
SjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUxldCdzIEVuY3J5cHQxIzAhBgNVBAMT
GkxldCdzIEVuY3J5cHQgQXV0aG9yaXR5IFgzMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEAnNMM8FrlLke3cl03g7NoYzDq1zUmGSXhvb418XCSL7e4S0EF
q6meNQhY7LEqxGiHC6PjdeTm86dicbp5gWAf15Gan/PQeGdxyGkOlZHP/uaZ6WA8
SMx+yk13EiSdRxta67nsHjcAHJyse6cF6s5K671B5TaYucv9bTyWaN8jKkKQDIZ0
Z8h/pZq4UmEUEz9l6YKHy9v6Dlb2honzhT+Xhq+w3Brvaw2VFn3EK6BlspkENnWA
a6xK8xuQSXgvopZPKiAlKQTGdMDQMc2PMTiVFrqoM7hD8bEfwzB/onkxEz0tNvjj
/PIzark5McWvxI0NHWQWM6r6hCm21AvA2H3DkwIDAQABo4IBfTCCAXkwEgYDVR0T
AQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwfwYIKwYBBQUHAQEEczBxMDIG
CCsGAQUFBzABhiZodHRwOi8vaXNyZy50cnVzdGlkLm9jc3AuaWRlbnRydXN0LmNv
bTA7BggrBgEFBQcwAoYvaHR0cDovL2FwcHMuaWRlbnRydXN0LmNvbS9yb290cy9k
c3Ryb290Y2F4My5wN2MwHwYDVR0jBBgwFoAUxKexpHsscfrb4UuQdf/EFWCFiRAw
VAYDVR0gBE0wSzAIBgZngQwBAgEwPwYLKwYBBAGC3xMBAQEwMDAuBggrBgEFBQcC
ARYiaHR0cDovL2Nwcy5yb290LXgxLmxldHNlbmNyeXB0Lm9yZzA8BgNVHR8ENTAz
MDGgL6AthitodHRwOi8vY3JsLmlkZW50cnVzdC5jb20vRFNUUk9PVENBWDNDUkwu
Y3JsMB0GA1UdDgQWBBSoSmpjBH3duubRObemRWXv86jsoTANBgkqhkiG9w0BAQsF
AAOCAQEA3TPXEfNjWDjdGBX7CVW+dla5cEilaUcne8IkCJLxWh9KEik3JHRRHGJo
uM2VcGfl96S8TihRzZvoroed6ti6WqEBmtzw3Wodatg+VyOeph4EYpr/1wXKtx8/
wApIvJSwtmVi4MFU5aMqrSDE6ea73Mj2tcMyo5jMd6jmeWUHK8so/joWUoHOUgwu
X4Po1QYz+3dszkDqMp4fklxBwXRsW10KXzPMTZ+sOPAveyxindmjkW8lGy+QsRlG
PfZ+G6Z6h7mjem0Y+iWlkYcV4PIWL1iwBi8saCbGS5jN2p8M+X+Q7UNKEkROb3N6
KOqkqm57TH2H3eDJAkSnh6/DNFu0Qg==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCmzkmfXydba2r8
tgUOBDVhkYuWkX8HYAA++8ffK4P4BxPiyQp1mi5Uv9rmleum25gvmW2E712VyNra
247aW1itvsOnTV0AgVokTw4Mp35K/nCvxVZBnKSrMyIwGC+KaXtMG9QdCO8ImDxT
Cac3RdJQgsK213q7io7NLt2UowywqljMuESUFPSvOGGxa6UOLLpoBlsxX3lt9Hvi
chawElpzLqPHeO/EC68K2QE5V06hUvoE+MMTdI0STndsBkYhvkD2uLQ7mOH/MEVP
q1BKNSEBROSa/2hk/njtGdV8OP6t28JWT98T4mejfqZxPXzdB39PLd9a6oWgzmD+
W6Gd+9YTAgMBAAECggEADnEJuryYQbf5GUwBAAepP3tEZJLQNqk/HDTcRxwTXuPt
+tKBD1F79WZu40vTjSyx7l0QOFQo/BDZsd0Ubx89fD1p3xA5nxOT5FTb2IifzIpe
4zjokOGo+BGDQjq10vvy6tH1+VWOrGXRwzawvX5UCRhpFz9sptQGLQmDsZy0Oo9B
LtavYVUqsbyqRWlzaclHgbythegIACWkqcalOzOtx+l6TGBRjej+c7URcwYBfr7t
XTAzbP+vnpaJovZyZT1eekr0OLzMpnjx4HvRvzL+NxauRpn6KfabsTfZlk8nrs4I
UdSjeukj1Iz8rGQilHdN/4dVJ3KzrlHVkVTBSjmMUQKBgQDaVXZnhAScfdiKeZbO
rdUAWcnwfkDghtRuAmzHaRM/FhFBEoVhdSbBuu+OUyBnIw/Ra4o2ePuEBcKIUiQO
w2tnE1CY5PPAcjw+OCSpvzy5xxjaqaRbm9BJp3FTeEYGLXERnchPpHg/NpexuF22
QOJ+FrysPyNMxuQp47ZwO9WT3QKBgQDDlSGjq/eeWxemwf7ZqMVlRyqsdJsgnCew
DkC62IGiYCBDfeEmndN+vcA/uzJHYV4iXiqS3aYJCWGaZFMhdIhIn5MgULvO1j5G
u/MxuzaaNPz22FlNCWTLBw4T1HOOvyTL+nLtZDKJ/BHxgHCmur1kiGvvZWrcCthD
afLEmseqrwKBgBuLZKCymxJTHhp6NHhmndSpfzyD8RNibzJhw+90ZiUzV4HqIEGn
Ufhm6Qn/mrroRXqaIpm0saZ6Q4yHMF1cchRS73wahlXlE4yV8KopojOd1pjfhgi4
o5JnOXjaV5s36GfcjATgLvtqm8CkDc6MaQaXP75LSNzKysYuIDoQkmVRAoGAAghF
rja2Pv4BU+lGJarcSj4gEmSvy/nza5/qSka/qhlHnIvtUAJp1TJRkhf24MkBOmgy
Fw6YkBV53ynVt05HsEGAPOC54t9VDFUdpNGmMpoEWuhKnUNQuc9b9RbLEJup3TjA
Avl8kPR+lzzXbtQX7biBLp6mKp0uPB0YubRGCN8CgYA0JMxK0x38Q2x3AQVhOmZh
YubtIa0JqVJhvpweOCFnkq3ebBpLsWYwiLTn86vuD0jupe5M3sxtefjkJmAKd8xY
aBU7QWhjh1fX4mzmggnbjcrIFbkIHsxwMeg567U/4AGxOOUsv9QUn37mqycqRKEn
YfUyYNLM6F3MmQAOs2kaHw==
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,13 @@
[Unit]
Description=MediaCMS uwsgi
[Service]
ExecStart=/home/mediacms.io/bin/uwsgi --ini /home/mediacms.io/mediacms/deploy/local_install/uwsgi.ini
ExecStop=/usr/bin/killall -9 uwsgi
RestartSec=3
#ExecRestart=killall -9 uwsgi; sleep 5; /home/sss/bin/uwsgi --ini /home/sss/wordgames/uwsgi.ini
Restart=always
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,7 @@
/home/mediacms.io/mediacms/logs/*.log {
weekly
missingok
rotate 7
compress
notifempty
}

View File

@@ -0,0 +1,38 @@
user www-data;
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 10240;
}
worker_rlimit_nofile 20000; #each connection needs a filehandle (or 2 if you are proxying)
http {
proxy_connect_timeout 75;
proxy_read_timeout 12000;
client_max_body_size 5800M;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 10;
types_hash_max_size 2048;
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
gzip on;
gzip_disable "msie6";
log_format compression '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" "$gzip_ratio"';
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}

View File

@@ -0,0 +1,34 @@
module selinux-mediacms 1.0;
require {
type init_t;
type var_t;
type redis_port_t;
type postgresql_port_t;
type httpd_t;
type httpd_sys_content_t;
type httpd_sys_rw_content_t;
class file { append create execute execute_no_trans getattr ioctl lock open read rename setattr unlink write };
class dir { add_name remove_name rmdir };
class tcp_socket name_connect;
class lnk_file read;
}
#============= httpd_t ==============
allow httpd_t var_t:file { getattr open read };
#============= init_t ==============
allow init_t postgresql_port_t:tcp_socket name_connect;
allow init_t redis_port_t:tcp_socket name_connect;
allow init_t httpd_sys_content_t:dir rmdir;
allow init_t httpd_sys_content_t:file { append create execute execute_no_trans ioctl lock open read rename setattr unlink write };
allow init_t httpd_sys_content_t:lnk_file read;
allow init_t httpd_sys_rw_content_t:dir { add_name remove_name rmdir };
allow init_t httpd_sys_rw_content_t:file { create ioctl lock open read setattr unlink write };

View File

@@ -0,0 +1,27 @@
[uwsgi]
chdir = /home/mediacms.io/mediacms/
virtualenv = /home/mediacms.io
module = cms.wsgi
uid=www-data
gid=www-data
processes = 2
threads = 2
master = true
socket = 127.0.0.1:9000
#socket = /home/mediacms.io/mediacms/deploy/uwsgi.sock
workers = 2
vacuum = true
logto = /home/mediacms.io/mediacms/logs/errorlog.txt
disable-logging = true
buffer-size=32768

View File

@@ -0,0 +1,16 @@
uwsgi_param QUERY_STRING $query_string;
uwsgi_param REQUEST_METHOD $request_method;
uwsgi_param CONTENT_TYPE $content_type;
uwsgi_param CONTENT_LENGTH $content_length;
uwsgi_param REQUEST_URI $request_uri;
uwsgi_param PATH_INFO $document_uri;
uwsgi_param DOCUMENT_ROOT $document_root;
uwsgi_param SERVER_PROTOCOL $server_protocol;
uwsgi_param REQUEST_SCHEME $scheme;
uwsgi_param HTTPS $https if_not_empty;
uwsgi_param REMOTE_ADDR $remote_addr;
uwsgi_param REMOTE_PORT $remote_port;
uwsgi_param SERVER_PORT $server_port;
uwsgi_param SERVER_NAME $server_name;

View File

@@ -23,7 +23,7 @@ and will start all services required for MediaCMS, as Celery/Redis for asynchron
For Django, the changes from the image produced by docker-compose.yaml are these:
* Django runs in debug mode, with `python manage.py runserver`
* gunicorn and nginx are not run
* uwsgi and nginx are not run
* Django runs in Debug mode, with Debug Toolbar
* Static files (js/css) are loaded from static/ folder
* corsheaders is installed and configured to allow all origins

View File

@@ -65,7 +65,6 @@ class CategoryAdminForm(forms.ModelForm):
class Meta:
model = Category
# LTI fields will be shown as read-only when USE_LTI is enabled
fields = '__all__'
def clean(self):
@@ -136,7 +135,7 @@ class CategoryAdmin(admin.ModelAdmin):
list_display = ["title", "user", "add_date", "media_count"]
list_filter = []
ordering = ("-add_date",)
readonly_fields = ("user", "media_count", "lti_platform", "lti_context_id")
readonly_fields = ("user", "media_count")
change_form_template = 'admin/files/category/change_form.html'
def get_list_filter(self, request):
@@ -168,14 +167,6 @@ class CategoryAdmin(admin.ModelAdmin):
),
]
additional_fieldsets = []
if getattr(settings, 'USE_LTI', False):
lti_fieldset = [
('LTI Integration', {'fields': ['lti_platform', 'lti_context_id'], 'classes': ['tab'], 'description': 'LTI/LMS integration settings (automatically managed by LTI provisioning)'}),
]
additional_fieldsets.extend(lti_fieldset)
if getattr(settings, 'USE_RBAC', False):
rbac_fieldset = [
('RBAC Settings', {'fields': ['is_rbac_category'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
@@ -186,9 +177,9 @@ class CategoryAdmin(admin.ModelAdmin):
('RBAC Settings', {'fields': ['is_rbac_category', 'identity_provider'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}),
]
additional_fieldsets.extend(rbac_fieldset)
return basic_fieldset + additional_fieldsets
return basic_fieldset + rbac_fieldset
else:
return basic_fieldset
class TagAdmin(admin.ModelAdmin):

View File

@@ -64,10 +64,4 @@ def stuff(request):
if request.user.is_superuser:
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL
if getattr(settings, 'USE_LTI', False):
lti_session = request.session.get('lti_session')
if lti_session and request.user.is_authenticated:
ret['lti_session'] = lti_session
return ret

View File

@@ -965,13 +965,3 @@ def get_alphanumeric_only(string):
"""
string = "".join([char for char in string if char.isalnum()])
return string.lower()
def get_alphanumeric_and_spaces(string):
"""Returns a query that contains only alphanumeric characters and spaces
This include characters other than the English alphabet too
"""
string = "".join([char for char in string if char.isalnum() or char.isspace()])
# Replace multiple spaces with single space and strip
string = " ".join(string.split())
return string

View File

@@ -1,22 +0,0 @@
# Generated by Django 5.2.6 on 2025-12-29 16:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0014_alter_subtitle_options_and_more'),
]
operations = [
migrations.AddField(
model_name='category',
name='is_lms_course',
field=models.BooleanField(db_index=True, default=False, help_text='Whether this category represents an LMS course'),
),
migrations.AddField(
model_name='category',
name='lti_context_id',
field=models.CharField(blank=True, db_index=True, help_text='LTI context ID from platform', max_length=255),
),
]

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.2.6 on 2025-12-29 16:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0015_category_is_lms_course_category_lti_context_id'),
('lti', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='category',
name='lti_platform',
field=models.ForeignKey(
blank=True, help_text='LTI Platform if this is an LTI course', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='categories', to='lti.ltiplatform'
),
),
]

View File

@@ -47,13 +47,6 @@ class Category(models.Model):
verbose_name='IDP Config Name',
)
# LTI/LMS integration fields
is_lms_course = models.BooleanField(default=False, db_index=True, help_text='Whether this category represents an LMS course')
lti_platform = models.ForeignKey('lti.LTIPlatform', blank=True, null=True, on_delete=models.SET_NULL, related_name='categories', help_text='LTI Platform if this is an LTI course')
lti_context_id = models.CharField(max_length=255, blank=True, db_index=True, help_text='LTI context ID from platform')
def __str__(self):
return self.title
@@ -144,7 +137,7 @@ class Tag(models.Model):
return True
def save(self, *args, **kwargs):
self.title = helpers.get_alphanumeric_and_spaces(self.title)
self.title = helpers.get_alphanumeric_only(self.title)
self.title = self.title[:100]
super(Tag, self).save(*args, **kwargs)

View File

@@ -352,11 +352,20 @@ class Media(models.Model):
# first get anything interesting out of the media
# that needs to be search able
a_tags = ""
a_tags = b_tags = ""
if self.id:
a_tags = " ".join([tag.title for tag in self.tags.all()])
b_tags = " ".join([tag.title.replace("-", " ") for tag in self.tags.all()])
items = [self.friendly_token, self.title, self.user.username, self.user.email, self.user.name, self.description, a_tags]
items = [
self.title,
self.user.username,
self.user.email,
self.user.name,
self.description,
a_tags,
b_tags,
]
for subtitle in self.subtitles.all():
items.append(subtitle.subtitle_text)

View File

@@ -80,7 +80,6 @@ urlpatterns = [
views.trim_video,
),
re_path(r"^api/v1/categories$", views.CategoryList.as_view()),
re_path(r"^api/v1/categories/contributor$", views.CategoryListContributor.as_view()),
re_path(r"^api/v1/tags$", views.TagList.as_view()),
re_path(r"^api/v1/comments$", views.CommentList.as_view()),
re_path(

View File

@@ -1,7 +1,7 @@
# Import all views for backward compatibility
from .auth import custom_login_view, saml_metadata # noqa: F401
from .categories import CategoryList, CategoryListContributor, TagList # noqa: F401
from .categories import CategoryList, TagList # noqa: F401
from .comments import CommentDetail, CommentList # noqa: F401
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401
from .media import MediaActions # noqa: F401

View File

@@ -43,40 +43,6 @@ class CategoryList(APIView):
return Response(ret)
class CategoryListContributor(APIView):
"""List categories where user has contributor access"""
@swagger_auto_schema(
manual_parameters=[],
tags=['Categories'],
operation_summary='Lists Categories for Contributors',
operation_description='Lists all categories where the user has contributor access',
responses={
200: openapi.Response('response description', CategorySerializer),
},
)
def get(self, request, format=None):
if not request.user.is_authenticated:
return Response([])
categories = Category.objects.none()
# Get global/public categories (non-RBAC)
public_categories = Category.objects.filter(is_rbac_category=False).prefetch_related("user")
# Get RBAC categories where user has contributor access
if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_contributor()
categories = public_categories.union(rbac_categories)
else:
categories = public_categories
categories = categories.order_by("title")
serializer = CategorySerializer(categories, many=True, context={"request": request})
return Response(serializer.data)
class TagList(APIView):
"""List tags"""

View File

@@ -24,7 +24,7 @@ from ..forms import (
WhisperSubtitlesForm,
)
from ..frontend_translations import translate_string
from ..helpers import get_alphanumeric_and_spaces
from ..helpers import get_alphanumeric_only
from ..methods import (
can_transcribe_video,
create_video_trim_request,
@@ -310,8 +310,8 @@ def edit_media(request):
media.tags.remove(tag)
if form.cleaned_data.get("new_tags"):
for tag in form.cleaned_data.get("new_tags").split(","):
tag = get_alphanumeric_and_spaces(tag)
tag = tag[:100]
tag = get_alphanumeric_only(tag)
tag = tag[:99]
if tag:
try:
tag = Tag.objects.get(title=tag)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
{
"editor.formatOnSave": true
"editor.formatOnSave": true,
"prettier.configPath": "../.prettierrc"
}

View File

@@ -5,5 +5,5 @@ module.exports = {
'^.+\\.tsx?$': 'ts-jest',
'^.+\\.jsx?$': 'babel-jest',
},
collectCoverageFrom: ['src/**'],
collectCoverageFrom: ['src/**', '!src/static/lib/**'],
};

View File

@@ -21,6 +21,9 @@
"@babel/core": "^7.26.9",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^12.1.5",
"@types/flux": "^3.1.15",
"@types/jest": "^29.5.12",
"@types/minimatch": "^5.1.2",

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -3,343 +3,278 @@ import { SiteContext } from '../../utils/contexts/';
import { useUser, usePopup } from '../../utils/hooks/';
import { PageStore, MediaPageStore } from '../../utils/stores/';
import { PageActions, MediaPageActions } from '../../utils/actions/';
import { formatInnerLink, publishedOnDate } from '../../utils/helpers/';
import { PopupMain, CircleIconButton, MaterialIcon, NavigationMenuList, NavigationContentApp } from '../_shared/';
import { formatInnerLink, inEmbeddedApp, publishedOnDate } from '../../utils/helpers/';
import { PopupMain } from '../_shared/';
import CommentsList from '../comments/Comments';
import { replaceString } from '../../utils/helpers/';
import { translateString } from '../../utils/helpers/';
function metafield(arr) {
let i;
let sep;
let ret = [];
let i;
let sep;
let ret = [];
if (arr && arr.length) {
i = 0;
sep = 1 < arr.length ? ', ' : '';
while (i < arr.length) {
ret[i] = (
<div key={i}>
<a href={arr[i].url} title={arr[i].title}>
{arr[i].title}
</a>
{i < arr.length - 1 ? sep : ''}
</div>
);
i += 1;
if (arr && arr.length) {
i = 0;
sep = 1 < arr.length ? ', ' : '';
while (i < arr.length) {
ret[i] = (
<div key={i}>
<a href={arr[i].url} title={arr[i].title}>
{arr[i].title}
</a>
{i < arr.length - 1 ? sep : ''}
</div>
);
i += 1;
}
}
}
return ret;
return ret;
}
function MediaAuthorBanner(props) {
return (
<div className="media-author-banner">
<div>
<a className="author-banner-thumb" href={props.link || null} title={props.name}>
<span style={{ backgroundImage: 'url(' + props.thumb + ')' }}>
<img src={props.thumb} loading="lazy" alt={props.name} title={props.name} />
</span>
</a>
</div>
<div>
<span>
<a href={props.link} className="author-banner-name" title={props.name}>
<span>{props.name}</span>
</a>
</span>
{PageStore.get('config-media-item').displayPublishDate && props.published ? (
<span className="author-banner-date">
{translateString('Published on')} {replaceString(publishedOnDate(new Date(props.published)))}
</span>
) : null}
</div>
</div>
);
return (
<div className="media-author-banner">
<div>
<a className="author-banner-thumb" href={props.link || null} title={props.name}>
<span style={{ backgroundImage: 'url(' + props.thumb + ')' }}>
<img src={props.thumb} loading="lazy" alt={props.name} title={props.name} />
</span>
</a>
</div>
<div>
<span>
<a href={props.link} className="author-banner-name" title={props.name}>
<span>{props.name}</span>
</a>
</span>
{PageStore.get('config-media-item').displayPublishDate && props.published ? (
<span className="author-banner-date">
{translateString('Published on')} {replaceString(publishedOnDate(new Date(props.published)))}
</span>
) : null}
</div>
</div>
);
}
function MediaMetaField(props) {
return (
<div className={props.id.trim() ? 'media-content-' + props.id.trim() : null}>
<div className="media-content-field">
<div className="media-content-field-label">
<h4>{props.title}</h4>
return (
<div className={props.id.trim() ? 'media-content-' + props.id.trim() : null}>
<div className="media-content-field">
<div className="media-content-field-label">
<h4>{props.title}</h4>
</div>
<div className="media-content-field-content">{props.value}</div>
</div>
</div>
<div className="media-content-field-content">{props.value}</div>
</div>
</div>
);
}
function getEditMenuItems() {
const items = [];
const friendlyToken = window.MediaCMS.mediaId;
const mediaData = MediaPageStore.get('media-data');
const mediaType = mediaData ? mediaData.media_type : null;
const isVideoOrAudio = mediaType === 'video' || mediaType === 'audio';
const allowMediaReplacement = window.MediaCMS.features.media.actions.allowMediaReplacement;
// Edit metadata - always available
items.push({
itemType: 'link',
link: `/edit?m=${friendlyToken}`,
text: translateString('Edit metadata'),
icon: 'edit',
});
// Trim - only for video/audio
if (isVideoOrAudio) {
items.push({
itemType: 'link',
link: `/edit_video?m=${friendlyToken}`,
text: translateString('Trim'),
icon: 'content_cut',
});
}
// Captions - only for video/audio
if (isVideoOrAudio) {
items.push({
itemType: 'link',
link: `/add_subtitle?m=${friendlyToken}`,
text: translateString('Captions'),
icon: 'closed_caption',
});
}
// Chapters - only for video/audio
if (isVideoOrAudio) {
items.push({
itemType: 'link',
link: `/edit_chapters?m=${friendlyToken}`,
text: 'Chapters',
icon: 'menu_book',
});
}
// Publish - always available
items.push({
itemType: 'link',
link: `/publish?m=${friendlyToken}`,
text: translateString('Publish'),
icon: 'publish',
});
// Replace - only if enabled
if (allowMediaReplacement) {
items.push({
itemType: 'link',
link: `/replace_media?m=${friendlyToken}`,
text: translateString('Replace'),
icon: 'swap_horiz',
});
}
return items;
);
}
function EditMediaButton(props) {
const [popupContentRef, PopupContent, PopupTrigger] = usePopup();
let link = props.link;
const menuItems = getEditMenuItems();
if (window.MediaCMS.site.devEnv) {
link = '/edit-media.html';
}
const popupPages = {
main: (
<div className="edit-options">
<PopupMain>
<NavigationMenuList items={menuItems} />
</PopupMain>
</div>
),
};
return (
<div className="edit-media-dropdown">
<PopupTrigger contentRef={popupContentRef}>
<button className="edit-media-icon" title={translateString('Edit media')}>
<i className="material-icons">edit</i>
</button>
</PopupTrigger>
<PopupContent contentRef={popupContentRef}>
<NavigationContentApp
initPage="main"
focusFirstItemOnPageChange={false}
pages={popupPages}
/>
</PopupContent>
</div>
);
return (
<a href={link} rel="nofollow" title={translateString('Edit media')} className="edit-media-icon">
<i className="material-icons">edit</i>
</a>
);
}
export default function ViewerInfoContent(props) {
const { userCan } = useUser();
const { userCan } = useUser();
const description = props.description.trim();
const tagsContent =
!PageStore.get('config-enabled').taxonomies.tags || PageStore.get('config-enabled').taxonomies.tags.enabled
? metafield(MediaPageStore.get('media-tags'))
: [];
const categoriesContent = PageStore.get('config-options').pages.media.categoriesWithTitle
? []
: !PageStore.get('config-enabled').taxonomies.categories ||
PageStore.get('config-enabled').taxonomies.categories.enabled
? metafield(MediaPageStore.get('media-categories'))
: [];
const description = props.description.trim();
const tagsContent =
!PageStore.get('config-enabled').taxonomies.tags || PageStore.get('config-enabled').taxonomies.tags.enabled
? metafield(MediaPageStore.get('media-tags'))
: [];
const categoriesContent = PageStore.get('config-options').pages.media.categoriesWithTitle
? []
: !PageStore.get('config-enabled').taxonomies.categories ||
PageStore.get('config-enabled').taxonomies.categories.enabled
? 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 [isContentVisible, setIsContentVisible] = useState('' == summary);
const [hasSummary, setHasSummary] = useState('' !== summary);
const [isContentVisible, setIsContentVisible] = useState('' == summary);
function proceedMediaRemoval() {
MediaPageActions.removeMedia();
popupContentRef.current.toggle();
}
function cancelMediaRemoval() {
popupContentRef.current.toggle();
}
function onMediaDelete(mediaId) {
// FIXME: Without delay creates conflict [ Uncaught Error: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. ].
setTimeout(function () {
PageActions.addNotification('Media removed. Redirecting...', 'mediaDelete');
setTimeout(function () {
window.location.href =
SiteContext._currentValue.url + '/' + MediaPageStore.get('media-data').author_profile.replace(/^\//g, '');
}, 2000);
}, 100);
if (void 0 !== mediaId) {
console.info("Removed media '" + mediaId + '"');
}
}
function onMediaDeleteFail(mediaId) {
// FIXME: Without delay creates conflict [ Uncaught Error: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. ].
setTimeout(function () {
PageActions.addNotification('Media removal failed', 'mediaDeleteFail');
}, 100);
if (void 0 !== mediaId) {
console.info('Media "' + mediaId + '"' + ' removal failed');
}
}
function onClickLoadMore() {
setIsContentVisible(!isContentVisible);
}
useEffect(() => {
MediaPageStore.on('media_delete', onMediaDelete);
MediaPageStore.on('media_delete_fail', onMediaDeleteFail);
return () => {
MediaPageStore.removeListener('media_delete', onMediaDelete);
MediaPageStore.removeListener('media_delete_fail', onMediaDeleteFail);
};
}, []);
const authorLink = formatInnerLink(props.author.url, SiteContext._currentValue.url);
const authorThumb = formatInnerLink(props.author.thumb, SiteContext._currentValue.url);
function setTimestampAnchors(text) {
function wrapTimestampWithAnchor(match, string) {
let split = match.split(':'),
s = 0,
m = 1;
while (split.length > 0) {
s += m * parseInt(split.pop(), 10);
m *= 60;
}
const wrapped = `<a href="#" data-timestamp="${s}" class="video-timestamp">${match}</a>`;
return wrapped;
function proceedMediaRemoval() {
MediaPageActions.removeMedia();
popupContentRef.current.toggle();
}
const timeRegex = new RegExp('((\\d)?\\d:)?(\\d)?\\d:\\d\\d', 'g');
return text.replace(timeRegex, wrapTimestampWithAnchor);
}
function cancelMediaRemoval() {
popupContentRef.current.toggle();
}
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}
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);
<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}
if (void 0 !== mediaId) {
console.info("Removed media '" + mediaId + '"');
}
}
{userCan.editMedia ? (
<div className="media-author-actions">
{userCan.editMedia ? <EditMediaButton /> : null}
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);
{userCan.deleteMedia ? (
<PopupTrigger contentRef={popupContentRef}>
<button className="remove-media-icon" title={translateString('Delete media')}>
<i className="material-icons">delete</i>
</button>
</PopupTrigger>
) : null}
if (void 0 !== mediaId) {
console.info('Media "' + mediaId + '"' + ' removal failed');
}
}
{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}
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');
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>
) : null}
</div>
</div>
<CommentsList />
</div>
);
{!inEmbeddedApp() && <CommentsList />}
</div>
);
}

View File

@@ -1,107 +1,119 @@
import React from 'react';
import { formatViewsNumber } from '../../utils/helpers/';
import { formatViewsNumber, inEmbeddedApp } from '../../utils/helpers/';
import { PageStore, MediaPageStore } from '../../utils/stores/';
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 { translateString } from '../../utils/helpers/';
export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
render() {
const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views;
render() {
const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views;
const mediaData = MediaPageStore.get('media-data');
const mediaState = mediaData.state;
const isShared = mediaData.is_shared;
const mediaData = MediaPageStore.get('media-data');
const mediaState = mediaData.state;
const isShared = mediaData.is_shared;
let stateTooltip = '';
let stateTooltip = '';
switch (mediaState) {
case 'private':
stateTooltip = 'The site admins have to make its access public';
break;
case 'unlisted':
stateTooltip = 'The site admins have to make it appear on listings';
break;
switch (mediaState) {
case 'private':
stateTooltip = 'The site admins have to make its access public';
break;
case 'unlisted':
stateTooltip = 'The site admins have to make it appear on listings';
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>
);
}
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}
{MemberContext._currentValue.can.shareMedia ? <MediaShareButton isVideo={true} /> : null}
{!MemberContext._currentValue.is.anonymous &&
MemberContext._currentValue.can.saveMedia &&
-1 < PlaylistsContext._currentValue.mediaTypes.indexOf(MediaPageStore.get('media-type')) ? (
<MediaSaveButton />
) : null}
{!this.props.allowDownload || !MemberContext._currentValue.can.downloadMedia ? null : !this
.downloadLink ? (
<VideoMediaDownloadLink />
) : (
<OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} />
)}
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
</div>
</div>
</div>
</div>
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -11,7 +11,7 @@ import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFi
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
import { BulkActionsModals } from '../components/BulkActionsModals';
import { translateString } from '../utils/helpers';
import { inEmbeddedApp, translateString } from '../utils/helpers';
import { withBulkActions } from '../utils/hoc/withBulkActions';
import { Page } from './_Page';
@@ -19,400 +19,443 @@ import { Page } from './_Page';
import '../components/profile-page/ProfilePage.scss';
function EmptySharedByMe(props) {
return (
<LinksConsumer>
{(links) => (
<div className="empty-media empty-channel-media">
<div className="welcome-title">No shared media</div>
<div className="start-uploading">
Media that you have shared with others will show up here.
</div>
</div>
)}
</LinksConsumer>
);
return (
<LinksConsumer>
{(links) => (
<div className="empty-media empty-channel-media">
<div className="welcome-title">No shared media</div>
<div className="start-uploading">Media that you have shared with others will show up here.</div>
</div>
)}
</LinksConsumer>
);
}
class ProfileSharedByMePage extends Page {
constructor(props, pageSlug) {
super(props, 'string' === typeof pageSlug ? pageSlug : 'author-shared-by-me');
constructor(props, pageSlug) {
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 = {
channelMediaCount: -1,
author: ProfilePageStore.get('author-data'),
uploadsPreviewItemsCount: 0,
title: this.props.title,
query: ProfilePageStore.get('author-query'),
requestUrl: null,
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: true,
filterArgs: '',
availableTags: [],
selectedTag: 'all',
selectedSort: 'date_added_desc',
};
this.state = {
channelMediaCount: -1,
author: ProfilePageStore.get('author-data'),
uploadsPreviewItemsCount: 0,
title: this.props.title,
query: ProfilePageStore.get('author-query'),
requestUrl: null,
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: true,
filterArgs: '',
availableTags: [],
selectedTag: 'all',
selectedSort: 'date_added_desc',
};
this.authorDataLoad = this.authorDataLoad.bind(this);
this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this);
this.getCountFunc = this.getCountFunc.bind(this);
this.changeRequestQuery = this.changeRequestQuery.bind(this);
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
this.onTagSelect = this.onTagSelect.bind(this);
this.onSortSelect = this.onSortSelect.bind(this);
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
this.authorDataLoad = this.authorDataLoad.bind(this);
this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this);
this.getCountFunc = this.getCountFunc.bind(this);
this.changeRequestQuery = this.changeRequestQuery.bind(this);
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
this.onTagSelect = this.onTagSelect.bind(this);
this.onSortSelect = this.onSortSelect.bind(this);
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
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;
}
ProfilePageStore.on('load-author-data', this.authorDataLoad);
}
this.setState({
author: author,
requestUrl: requestUrl,
});
}
componentDidMount() {
ProfilePageActions.load_author_data();
}
onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) {
this.setState({
uploadsPreviewItemsCount: totalAuthorPreviewItems,
});
}
authorDataLoad() {
const author = ProfilePageStore.get('author-data');
getCountFunc(count) {
this.setState(
{
channelMediaCount: count,
},
() => {
if (this.state.query) {
let title = '';
let requestUrl = this.state.requestUrl;
if (!count) {
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,
});
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;
}
}
}
);
}
changeRequestQuery(newQuery) {
if (!this.state.author) {
return;
this.setState({
author: author,
requestUrl: requestUrl,
});
}
let requestUrl;
if (newQuery) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me&q=' + encodeURIComponent(newQuery) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me' + this.state.filterArgs;
onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) {
this.setState({
uploadsPreviewItemsCount: totalAuthorPreviewItems,
});
}
let title = this.state.title;
getCountFunc(count) {
this.setState(
{
channelMediaCount: count,
},
() => {
if (this.state.query) {
let title = '';
if ('' === newQuery) {
title = this.props.title;
if (!count) {
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({
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 () {
changeRequestQuery(newQuery) {
if (!this.state.author) {
return;
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;
if (newQuery) {
requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_by_me&q=' +
encodeURIComponent(newQuery) +
this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me' + this.state.filterArgs;
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({
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() {
const authorData = ProfilePageStore.get('author-data');
onToggleFiltersClick() {
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
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=')
);
onToggleSortingClick() {
this.setState({
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: !this.state.hiddenSorting,
});
}
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'}
/>
) : 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,
];
}
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) {
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 = {
title: PropTypes.string.isRequired,
bulkActions: PropTypes.object.isRequired,
title: PropTypes.string.isRequired,
bulkActions: PropTypes.object.isRequired,
};
ProfileSharedByMePage.defaultProps = {
title: 'Shared by me',
title: 'Shared by me',
};
// Wrap with HOC and export as named export for compatibility

View File

@@ -10,364 +10,404 @@ import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListA
import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFilters';
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
import { translateString } from '../utils/helpers';
import { inEmbeddedApp, translateString } from '../utils/helpers';
import { Page } from './_Page';
import '../components/profile-page/ProfilePage.scss';
function EmptySharedWithMe(props) {
return (
<LinksConsumer>
{(links) => (
<div className="empty-media empty-channel-media">
<div className="welcome-title">No shared media</div>
<div className="start-uploading">
Media that others have shared with you will show up here.
</div>
</div>
)}
</LinksConsumer>
);
return (
<LinksConsumer>
{(links) => (
<div className="empty-media empty-channel-media">
<div className="welcome-title">No shared media</div>
<div className="start-uploading">Media that others have shared with you will show up here.</div>
</div>
)}
</LinksConsumer>
);
}
export class ProfileSharedWithMePage extends Page {
constructor(props, pageSlug) {
super(props, 'string' === typeof pageSlug ? pageSlug : 'author-shared-with-me');
constructor(props, pageSlug) {
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 = {
channelMediaCount: -1,
author: ProfilePageStore.get('author-data'),
uploadsPreviewItemsCount: 0,
title: this.props.title,
query: ProfilePageStore.get('author-query'),
requestUrl: null,
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: true,
filterArgs: '',
availableTags: [],
selectedTag: 'all',
selectedSort: 'date_added_desc',
};
this.state = {
channelMediaCount: -1,
author: ProfilePageStore.get('author-data'),
uploadsPreviewItemsCount: 0,
title: this.props.title,
query: ProfilePageStore.get('author-query'),
requestUrl: null,
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: true,
filterArgs: '',
availableTags: [],
selectedTag: 'all',
selectedSort: 'date_added_desc',
};
this.authorDataLoad = this.authorDataLoad.bind(this);
this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this);
this.getCountFunc = this.getCountFunc.bind(this);
this.changeRequestQuery = this.changeRequestQuery.bind(this);
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
this.onTagSelect = this.onTagSelect.bind(this);
this.onSortSelect = this.onSortSelect.bind(this);
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
this.authorDataLoad = this.authorDataLoad.bind(this);
this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this);
this.getCountFunc = this.getCountFunc.bind(this);
this.changeRequestQuery = this.changeRequestQuery.bind(this);
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
this.onTagSelect = this.onTagSelect.bind(this);
this.onSortSelect = this.onSortSelect.bind(this);
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
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;
}
ProfilePageStore.on('load-author-data', this.authorDataLoad);
}
this.setState({
author: author,
requestUrl: requestUrl,
});
}
componentDidMount() {
ProfilePageActions.load_author_data();
}
onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) {
this.setState({
uploadsPreviewItemsCount: totalAuthorPreviewItems,
});
}
authorDataLoad() {
const author = ProfilePageStore.get('author-data');
getCountFunc(count) {
this.setState(
{
channelMediaCount: count,
},
() => {
if (this.state.query) {
let title = '';
let requestUrl = this.state.requestUrl;
if (!count) {
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,
});
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;
}
}
}
);
}
changeRequestQuery(newQuery) {
if (!this.state.author) {
return;
this.setState({
author: author,
requestUrl: requestUrl,
});
}
let requestUrl;
if (newQuery) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me&q=' + encodeURIComponent(newQuery) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me' + this.state.filterArgs;
onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) {
this.setState({
uploadsPreviewItemsCount: totalAuthorPreviewItems,
});
}
let title = this.state.title;
getCountFunc(count) {
this.setState(
{
channelMediaCount: count,
},
() => {
if (this.state.query) {
let title = '';
if ('' === newQuery) {
title = this.props.title;
if (!count) {
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({
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 () {
changeRequestQuery(newQuery) {
if (!this.state.author) {
return;
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;
if (newQuery) {
requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_with_me&q=' +
encodeURIComponent(newQuery) +
this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me' + this.state.filterArgs;
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({
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() {
const authorData = ProfilePageStore.get('author-data');
onToggleFiltersClick() {
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
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=')
);
onToggleSortingClick() {
this.setState({
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: !this.state.hiddenSorting,
});
}
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'}
/>
) : 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,
];
}
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) {
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 = {
title: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
};
ProfileSharedWithMePage.defaultProps = {
title: 'Shared with me',
title: 'Shared with me',
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -9,88 +9,89 @@ let browserCache;
const _StoreData = {};
class VideoPlayerStore extends EventEmitter {
constructor() {
super();
constructor() {
super();
this.mediacms_config = mediacmsConfig(window.MediaCMS);
this.mediacms_config = mediacmsConfig(window.MediaCMS);
browserCache = new BrowserCache(this.mediacms_config.site.id, 86400); // Keep cache data "fresh" for one day.
browserCache = new BrowserCache(this.mediacms_config.site.id, 86400); // Keep cache data "fresh" for one day.
_StoreData.inTheaterMode = browserCache.get('in-theater-mode');
_StoreData.inTheaterMode = null !== _StoreData.inTheaterMode ? _StoreData.inTheaterMode : !1;
_StoreData.inTheaterMode = browserCache.get('in-theater-mode');
_StoreData.inTheaterMode = null !== _StoreData.inTheaterMode ? _StoreData.inTheaterMode : !1;
_StoreData.playerVolume = browserCache.get('player-volume');
_StoreData.playerVolume =
null === _StoreData.playerVolume ? 1 : Math.max(Math.min(Number(_StoreData.playerVolume), 1), 0);
_StoreData.playerVolume = browserCache.get('player-volume');
_StoreData.playerVolume =
null === _StoreData.playerVolume ? 1 : Math.max(Math.min(Number(_StoreData.playerVolume), 1), 0);
_StoreData.playerSoundMuted = browserCache.get('player-sound-muted');
_StoreData.playerSoundMuted = null !== _StoreData.playerSoundMuted ? _StoreData.playerSoundMuted : !1;
_StoreData.playerSoundMuted = browserCache.get('player-sound-muted');
_StoreData.playerSoundMuted = null !== _StoreData.playerSoundMuted ? _StoreData.playerSoundMuted : !1;
_StoreData.videoQuality = browserCache.get('video-quality');
_StoreData.videoQuality = null !== _StoreData.videoQuality ? _StoreData.videoQuality : 'Auto';
_StoreData.videoQuality = browserCache.get('video-quality');
_StoreData.videoQuality = null !== _StoreData.videoQuality ? _StoreData.videoQuality : 'Auto';
_StoreData.videoPlaybackSpeed = browserCache.get('video-playback-speed');
_StoreData.videoPlaybackSpeed = null !== _StoreData.videoPlaybackSpeed ? _StoreData.videoPlaybackSpeed : !1;
}
get(type) {
let r = null;
switch (type) {
case 'player-volume':
r = _StoreData.playerVolume;
break;
case 'player-sound-muted':
r = _StoreData.playerSoundMuted;
break;
case 'in-theater-mode':
r = _StoreData.inTheaterMode;
break;
case 'video-data':
r = _StoreData.videoData;
break;
case 'video-quality':
r = _StoreData.videoQuality;
break;
case 'video-playback-speed':
r = _StoreData.videoPlaybackSpeed;
break;
_StoreData.videoPlaybackSpeed = browserCache.get('video-playback-speed');
_StoreData.videoPlaybackSpeed = null !== _StoreData.videoPlaybackSpeed ? _StoreData.videoPlaybackSpeed : !1;
}
return r;
}
actions_handler(action) {
switch (action.type) {
case 'TOGGLE_VIEWER_MODE':
_StoreData.inTheaterMode = !_StoreData.inTheaterMode;
this.emit('changed_viewer_mode');
break;
case 'SET_VIEWER_MODE':
_StoreData.inTheaterMode = action.inTheaterMode;
browserCache.set('in-theater-mode', _StoreData.inTheaterMode);
this.emit('changed_viewer_mode');
break;
case 'SET_PLAYER_VOLUME':
_StoreData.playerVolume = action.playerVolume;
browserCache.set('player-volume', action.playerVolume);
this.emit('changed_player_volume');
break;
case 'SET_PLAYER_SOUND_MUTED':
_StoreData.playerSoundMuted = action.playerSoundMuted;
browserCache.set('player-sound-muted', action.playerSoundMuted);
this.emit('changed_player_sound_muted');
break;
case 'SET_VIDEO_QUALITY':
_StoreData.videoQuality = action.quality;
browserCache.set('video-quality', action.quality);
this.emit('changed_video_quality');
break;
case 'SET_VIDEO_PLAYBACK_SPEED':
_StoreData.videoPlaybackSpeed = action.playbackSpeed;
browserCache.set('video-playback-speed', action.playbackSpeed);
this.emit('changed_video_playback_speed');
break;
get(type) {
let r = null;
switch (type) {
case 'player-volume':
r = _StoreData.playerVolume;
break;
case 'player-sound-muted':
r = _StoreData.playerSoundMuted;
break;
case 'in-theater-mode':
r = _StoreData.inTheaterMode;
break;
case 'video-data':
r = _StoreData.videoData;
break;
case 'video-quality':
r = _StoreData.videoQuality;
break;
case 'video-playback-speed':
r = _StoreData.videoPlaybackSpeed;
break;
}
return r;
}
actions_handler(action) {
switch (action.type) {
case 'TOGGLE_VIEWER_MODE':
_StoreData.inTheaterMode = !_StoreData.inTheaterMode;
browserCache.set('in-theater-mode', _StoreData.inTheaterMode);
this.emit('changed_viewer_mode');
break;
case 'SET_VIEWER_MODE':
_StoreData.inTheaterMode = action.inTheaterMode;
browserCache.set('in-theater-mode', _StoreData.inTheaterMode);
this.emit('changed_viewer_mode');
break;
case 'SET_PLAYER_VOLUME':
_StoreData.playerVolume = action.playerVolume;
browserCache.set('player-volume', action.playerVolume);
this.emit('changed_player_volume');
break;
case 'SET_PLAYER_SOUND_MUTED':
_StoreData.playerSoundMuted = action.playerSoundMuted;
browserCache.set('player-sound-muted', action.playerSoundMuted);
this.emit('changed_player_sound_muted');
break;
case 'SET_VIDEO_QUALITY':
_StoreData.videoQuality = action.quality;
browserCache.set('video-quality', action.quality);
this.emit('changed_video_quality');
break;
case 'SET_VIDEO_PLAYBACK_SPEED':
_StoreData.videoPlaybackSpeed = action.playbackSpeed;
browserCache.set('video-playback-speed', action.playbackSpeed);
this.emit('changed_video_playback_speed');
break;
}
}
}
}
export default exportStore(new VideoPlayerStore(), 'actions_handler');

View File

@@ -0,0 +1,385 @@
export const sampleGlobalMediaCMS = {
profileId: 'john',
site: {
id: 'my-site',
url: 'https://example.com/',
api: 'https://example.com/api/',
title: 'Example',
theme: { mode: 'dark', switch: { enabled: true, position: 'sidebar' } },
logo: {
lightMode: { img: '/img/light.png', svg: '/img/light.svg' },
darkMode: { img: '/img/dark.png', svg: '/img/dark.svg' },
},
devEnv: false,
useRoundedCorners: true,
version: '1.0.0',
taxonomies: {
tags: { enabled: true, title: 'Topic Tags' },
categories: { enabled: false, title: 'Kinds' },
},
pages: {
featured: { enabled: true, title: 'Featured picks' },
latest: { enabled: true, title: 'Recent uploads' },
members: { enabled: true, title: 'People' },
recommended: { enabled: false, title: 'You may like' },
},
userPages: {
liked: { enabled: true, title: 'Favorites' },
history: { enabled: true, title: 'Watched' },
},
},
url: {
home: '/',
admin: '/admin',
error404: '/404',
latestMedia: '/latest',
featuredMedia: '/featured',
recommendedMedia: '/recommended',
signin: '/signin',
signout: '/signout',
register: '/register',
changePassword: '/password',
members: '/members',
search: '/search',
likedMedia: '/liked',
history: '/history',
addMedia: '/add',
editChannel: '/edit/channel',
editProfile: '/edit/profile',
tags: '/tags',
categories: '/categories',
manageMedia: '/manage/media',
manageUsers: '/manage/users',
manageComments: '/manage/comments',
},
api: {
media: 'v1/media/',
playlists: 'v1/playlists',
members: 'v1/users',
liked: 'v1/user/liked',
history: 'v1/user/history',
tags: 'v1/tags',
categories: 'v1/categories',
manage_media: 'v1/manage/media',
manage_users: 'v1/manage/users',
manage_comments: 'v1/manage/comments',
search: 'v1/search',
actions: 'v1/actions',
comments: 'v1/comments',
},
contents: {
header: {
right: '',
onLogoRight: '',
},
notifications: {
messages: { addToLiked: 'Yay', removeFromLiked: 'Oops', addToDisliked: 'nay', removeFromDisliked: 'ok' },
},
sidebar: {
belowNavMenu: '__belowNavMenu__',
belowThemeSwitcher: '__belowThemeSwitcher__',
footer: '__footer__',
mainMenuExtraItems: [
{ text: '__text_1__', link: '__link_1__', icon: '__icon_1__', className: '__className_1__' },
],
navMenuItems: [
{ text: '__text_2__', link: '__link_2__', icon: '__icon_2__', className: '__className_2__' },
],
},
uploader: {
belowUploadArea: '__belowUploadArea__',
postUploadMessage: '__postUploadMessage__',
},
},
pages: {
home: {
sections: {
latest: { title: 'Latest T' },
featured: { title: 'Featured T' },
recommended: { title: 'Recommended T' },
},
},
media: { categoriesWithTitle: true, htmlInDescription: true, hideViews: true, related: { initialSize: 5 } },
profile: { htmlInDescription: true, includeHistory: true, includeLikedMedia: true },
search: { advancedFilters: true },
},
features: {
mediaItem: { hideAuthor: true, hideViews: false, hideDate: true },
media: {
actions: {
like: true,
dislike: true,
report: true,
comment: true,
comment_mention: true,
download: true,
save: true,
share: true,
},
shareOptions: ['embed', 'email'],
},
playlists: { mediaTypes: ['audio'] },
sideBar: { hideHomeLink: false, hideTagsLink: true, hideCategoriesLink: false },
embeddedVideo: { initialDimensions: { width: 640, height: 360 } },
headerBar: { hideLogin: false, hideRegister: true },
},
user: {
is: { anonymous: false, admin: true },
name: ' John ',
username: ' john ',
thumbnail: ' /img/j.png ',
can: {
changePassword: true,
deleteProfile: true,
addComment: true,
mentionComment: true,
deleteComment: true,
editMedia: true,
deleteMedia: true,
editSubtitle: true,
manageMedia: true,
manageUsers: true,
manageComments: true,
contactUser: true,
canSeeMembersPage: true,
usersNeedsToBeApproved: false,
addMedia: true,
editProfile: true,
readComment: true,
},
pages: { about: '/u/john/about ', media: '/u/john ', playlists: '/u/john/playlists ' },
},
};
export const sampleMediaCMSConfig = {
api: {
archive: {
tags: '',
categories: '',
},
featured: '',
manage: {
media: '',
users: '',
comments: '',
},
media: '',
playlists: '/v1/playlists',
recommended: '',
search: {
query: '',
titles: './search.html?titles=',
tag: '',
category: '',
},
user: {
liked: '',
history: '',
playlists: '/playlists/?author=',
},
users: '/users',
},
contents: {
header: {
right: '',
onLogoRight: '',
},
uploader: {
belowUploadArea: '',
postUploadMessage: '',
},
sidebar: {
belowNavMenu: '__belowNavMenu__',
belowThemeSwitcher: '__belowThemeSwitcher__',
footer: '__footer__',
mainMenuExtra: {
items: [{ text: '__text_1__', link: '__link_1__', icon: '__icon_1__', className: '__className_1__' }],
},
navMenu: {
items: [{ text: '__text_2__', link: '__link_2__', icon: '__icon_2__', className: '__className_2__' }],
},
},
},
enabled: {
taxonomies: sampleGlobalMediaCMS.site.taxonomies,
pages: {
featured: { enabled: true, title: 'Featured picks' },
latest: { enabled: true, title: 'Recent uploads' },
members: { enabled: true, title: 'People' },
recommended: { enabled: true, title: 'You may like' },
liked: { enabled: true, title: 'Favorites' },
history: { enabled: true, title: 'Watched' },
},
},
member: {
name: null,
username: 'john',
thumbnail: null,
is: {
admin: false,
anonymous: false,
},
can: {
addComment: false,
addMedia: false,
canSeeMembersPage: false,
changePassword: false,
contactUser: false,
deleteComment: false,
deleteMedia: false,
deleteProfile: false,
dislikeMedia: false,
downloadMedia: false,
editMedia: false,
editProfile: false,
editSubtitle: false,
likeMedia: false,
login: false,
manageComments: false,
manageMedia: false,
manageUsers: false,
mentionComment: false,
readComment: true,
register: false,
reportMedia: false,
saveMedia: true,
shareMedia: false,
usersNeedsToBeApproved: false,
},
pages: {
home: null,
about: null,
media: null,
playlists: null,
},
},
media: {
item: {
displayAuthor: false,
displayViews: false,
displayPublishDate: false,
},
share: {
options: [],
},
},
notifications: {
messages: {
addToLiked: '',
removeFromLiked: '',
addToDisliked: '',
removeFromDisliked: '',
},
},
options: {
pages: {
home: {
sections: {
latest: {
title: '',
},
featured: {
title: '',
},
recommended: {
title: '',
},
},
},
search: {
advancedFilters: false,
},
media: {
categoriesWithTitle: true,
htmlInDescription: true,
related: { initialSize: 5 },
displayViews: true,
},
profile: {
htmlInDescription: false,
includeHistory: false,
includeLikedMedia: false,
},
},
embedded: {
video: {
dimensions: {
width: 0,
widthUnit: 'px',
height: 0,
heightUnit: 'px',
},
},
},
},
playlists: {
mediaTypes: [],
},
sidebar: {
hideHomeLink: false,
hideTagsLink: false,
hideCategoriesLink: false,
},
site: {
api: '',
id: '',
title: '',
url: '',
useRoundedCorners: false,
version: '',
},
theme: {
logo: {
lightMode: { img: '/img/light.png', svg: '/img/light.svg' },
darkMode: { img: '/img/dark.png', svg: '/img/dark.svg' },
},
mode: 'dark',
switch: {
enabled: true,
position: 'sidebar',
},
},
url: {
admin: '',
archive: {
categories: '',
tags: '',
},
changePassword: '',
embed: '',
error404: '',
featured: '',
home: '',
latest: '',
manage: {
comments: '',
media: '',
users: '',
},
members: '',
profile: {
about: '',
media: '',
playlists: '',
shared_by_me: '',
shared_with_me: '',
},
recommended: '',
register: '',
search: {
base: '',
category: '',
query: '',
tag: '',
},
signin: '',
signout: '',
user: {
addMedia: '',
editChannel: '',
editProfile: '',
history: '',
liked: '',
},
},
};

View File

@@ -0,0 +1,145 @@
import * as MediaPageActions from '../../../src/static/js/utils/actions/MediaPageActions';
import dispatcher from '../../../src/static/js/utils/dispatcher';
// Mock the dispatcher module used by MediaPageActions
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
describe('utils/actions', () => {
describe('MediaPageActions', () => {
const dispatch = dispatcher.dispatch;
beforeEach(() => {
(dispatcher.dispatch as jest.Mock).mockClear();
});
describe('loadMediaData', () => {
it('Should dispatch LOAD_MEDIA_DATA action', () => {
MediaPageActions.loadMediaData();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'LOAD_MEDIA_DATA' });
});
});
describe('likeMedia / dislikeMedia', () => {
it('Should dispatch LIKE_MEDIA action', () => {
MediaPageActions.likeMedia();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'LIKE_MEDIA' });
});
it('Should dispatch DISLIKE_MEDIA action', () => {
MediaPageActions.dislikeMedia();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'DISLIKE_MEDIA' });
});
});
describe('reportMedia', () => {
it('Should dispatch REPORT_MEDIA with empty string when description is undefined', () => {
MediaPageActions.reportMedia();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'REPORT_MEDIA', reportDescription: '' });
});
// @todo: Revisit this behavior
it('Should dispatch REPORT_MEDIA with stripped description when provided', () => {
MediaPageActions.reportMedia(' some text ');
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'REPORT_MEDIA', reportDescription: 'sometext' });
});
// @todo: Revisit this behavior
it('Should remove all whitespace characters including newlines and tabs', () => {
MediaPageActions.reportMedia('\n\t spaced\ntext \t');
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'REPORT_MEDIA', reportDescription: 'spacedtext' });
});
});
describe('copyShareLink / copyEmbedMediaCode', () => {
it('Should dispatch COPY_SHARE_LINK carrying the provided input element', () => {
const inputElem = document.createElement('input');
MediaPageActions.copyShareLink(inputElem);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'COPY_SHARE_LINK', inputElement: inputElem });
});
it('Should dispatch COPY_EMBED_MEDIA_CODE carrying the provided textarea element', () => {
const textarea = document.createElement('textarea');
MediaPageActions.copyEmbedMediaCode(textarea);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'COPY_EMBED_MEDIA_CODE', inputElement: textarea });
});
});
describe('removeMedia', () => {
it('Should dispatch REMOVE_MEDIA action', () => {
MediaPageActions.removeMedia();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'REMOVE_MEDIA' });
});
});
describe('comments', () => {
it('Should dispatch SUBMIT_COMMENT with provided text', () => {
const commentText = 'Nice one';
MediaPageActions.submitComment(commentText);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'SUBMIT_COMMENT', commentText });
});
it('Should dispatch DELETE_COMMENT with provided comment id', () => {
const commentId = 'c-123';
MediaPageActions.deleteComment(commentId);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'DELETE_COMMENT', commentId });
});
// @todo: Revisit this behavior
it('Should dispatch DELETE_COMMENT with numeric comment id', () => {
const commentId = 42;
MediaPageActions.deleteComment(commentId);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'DELETE_COMMENT', commentId });
});
});
describe('playlists', () => {
it('Should dispatch CREATE_PLAYLIST with provided data', () => {
const payload = { title: 'My list', description: 'Desc' };
MediaPageActions.createPlaylist(payload);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'CREATE_PLAYLIST', playlist_data: payload });
});
it('Should dispatch ADD_MEDIA_TO_PLAYLIST with ids', () => {
const playlist_id = 'pl-1';
const media_id = 'm-1';
MediaPageActions.addMediaToPlaylist(playlist_id, media_id);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'ADD_MEDIA_TO_PLAYLIST', playlist_id, media_id });
});
it('Should dispatch REMOVE_MEDIA_FROM_PLAYLIST with ids', () => {
const playlist_id = 'pl-1';
const media_id = 'm-1';
MediaPageActions.removeMediaFromPlaylist(playlist_id, media_id);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'REMOVE_MEDIA_FROM_PLAYLIST', playlist_id, media_id });
});
it('Should dispatch APPEND_NEW_PLAYLIST with provided playlist data', () => {
const playlist_data = {
playlist_id: 'pl-2',
add_date: new Date('2020-01-01T00:00:00Z'),
description: 'Cool',
title: 'T',
media_list: ['a', 'b'],
};
MediaPageActions.addNewPlaylist(playlist_data);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'APPEND_NEW_PLAYLIST', playlist_data });
});
});
});
});

View File

@@ -0,0 +1,55 @@
import * as PageActions from '../../../src/static/js/utils/actions/PageActions';
import dispatcher from '../../../src/static/js/utils/dispatcher';
// Mock the dispatcher module used by PageActions
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
describe('utils/actions', () => {
describe('PageActions', () => {
const dispatch = dispatcher.dispatch;
beforeEach(() => {
(dispatcher.dispatch as jest.Mock).mockClear();
});
describe('initPage', () => {
it('Should dispatch INIT_PAGE with provided page string', () => {
PageActions.initPage('home');
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'INIT_PAGE', page: 'home' });
});
// @todo: Revisit this behavior
it('Should dispatch INIT_PAGE with empty string', () => {
PageActions.initPage('');
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'INIT_PAGE', page: '' });
});
});
describe('toggleMediaAutoPlay', () => {
it('Should dispatch TOGGLE_AUTO_PLAY action', () => {
PageActions.toggleMediaAutoPlay();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_AUTO_PLAY' });
});
});
describe('addNotification', () => {
it('Should dispatch ADD_NOTIFICATION with message and id', () => {
const notification = 'Saved!';
const notificationId = 'notif-1';
PageActions.addNotification(notification, notificationId);
expect(dispatch).toHaveBeenCalledWith({ type: 'ADD_NOTIFICATION', notification, notificationId });
});
// @todo: Revisit this behavior
it('Should dispatch ADD_NOTIFICATION with empty notification message', () => {
const notification = '';
const notificationId = 'id-empty';
PageActions.addNotification(notification, notificationId);
expect(dispatch).toHaveBeenCalledWith({ type: 'ADD_NOTIFICATION', notification, notificationId });
});
});
});
});

View File

@@ -0,0 +1,96 @@
import { PlaylistPageActions } from '../../../src/static/js/utils/actions';
import dispatcher from '../../../src/static/js/utils/dispatcher';
// Mock the dispatcher module used by PlaylistPageActions
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
describe('utils/actions', () => {
describe('PlaylistPageActions', () => {
const dispatch = dispatcher.dispatch;
beforeEach(() => {
(dispatcher.dispatch as jest.Mock).mockClear();
});
describe('loadPlaylistData', () => {
it('Should dispatch LOAD_PLAYLIST_DATA action', () => {
PlaylistPageActions.loadPlaylistData();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'LOAD_PLAYLIST_DATA' });
});
});
describe('toggleSave', () => {
it('Should dispatch TOGGLE_SAVE action', () => {
PlaylistPageActions.toggleSave();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SAVE' });
});
});
describe('updatePlaylist', () => {
it('Should dispatch UPDATE_PLAYLIST with provided title and description', () => {
const payload = { title: 'My Playlist', description: 'A description' };
PlaylistPageActions.updatePlaylist(payload);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'UPDATE_PLAYLIST', playlist_data: payload });
});
// @todo: Revisit this behavior
it('Should dispatch UPDATE_PLAYLIST with empty strings for title and description', () => {
const payload = { title: '', description: '' };
PlaylistPageActions.updatePlaylist(payload);
expect(dispatch).toHaveBeenCalledWith({ type: 'UPDATE_PLAYLIST', playlist_data: payload });
});
});
describe('removePlaylist', () => {
it('Should dispatch REMOVE_PLAYLIST action', () => {
PlaylistPageActions.removePlaylist();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'REMOVE_PLAYLIST' });
});
});
describe('removedMediaFromPlaylist', () => {
it('Should dispatch MEDIA_REMOVED_FROM_PLAYLIST with media and playlist ids', () => {
PlaylistPageActions.removedMediaFromPlaylist('m1', 'p1');
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({
type: 'MEDIA_REMOVED_FROM_PLAYLIST',
media_id: 'm1',
playlist_id: 'p1',
});
});
// @todo: Revisit this behavior
it('Should dispatch MEDIA_REMOVED_FROM_PLAYLIST with empty ids as strings', () => {
PlaylistPageActions.removedMediaFromPlaylist('', '');
expect(dispatch).toHaveBeenCalledWith({
type: 'MEDIA_REMOVED_FROM_PLAYLIST',
media_id: '',
playlist_id: '',
});
});
});
describe('reorderedMediaInPlaylist', () => {
it('Should dispatch PLAYLIST_MEDIA_REORDERED with provided array', () => {
const items = [
{ id: '1', url: '/1', thumbnail_url: '/t1' },
{ id: '2', url: '/2', thumbnail_url: '/t2' },
];
PlaylistPageActions.reorderedMediaInPlaylist(items);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'PLAYLIST_MEDIA_REORDERED', playlist_media: items });
});
// @todo: Revisit this behavior
it('Should dispatch PLAYLIST_MEDIA_REORDERED with empty array for playlist media', () => {
const items: any[] = [];
PlaylistPageActions.reorderedMediaInPlaylist(items);
expect(dispatch).toHaveBeenCalledWith({ type: 'PLAYLIST_MEDIA_REORDERED', playlist_media: items });
});
});
});
});

View File

@@ -0,0 +1,39 @@
import { PlaylistViewActions } from '../../../src/static/js/utils/actions';
import dispatcher from '../../../src/static/js/utils/dispatcher';
// Mock the dispatcher module used by PlaylistViewActions
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
describe('utils/actions', () => {
describe('PlaylistViewActions', () => {
const dispatch = dispatcher.dispatch;
beforeEach(() => {
(dispatcher.dispatch as jest.Mock).mockClear();
});
describe('toggleLoop', () => {
it('Should dispatch TOGGLE_LOOP action', () => {
PlaylistViewActions.toggleLoop();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_LOOP' });
});
});
describe('toggleShuffle', () => {
it('Should dispatch TOGGLE_SHUFFLE action', () => {
PlaylistViewActions.toggleShuffle();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SHUFFLE' });
});
});
describe('toggleSave', () => {
it('Should dispatch TOGGLE_SAVE action', () => {
PlaylistViewActions.toggleSave();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SAVE' });
});
});
});
});

View File

@@ -0,0 +1,27 @@
import { ProfilePageActions } from '../../../src/static/js/utils/actions';
import dispatcher from '../../../src/static/js/utils/dispatcher';
// Mock the dispatcher module used by ProfilePageActions
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
describe('utils/actions', () => {
describe('ProfilePageActions', () => {
const dispatch = dispatcher.dispatch;
beforeEach(() => {
(dispatcher.dispatch as jest.Mock).mockClear();
});
it('Should dispatch LOAD_AUTHOR_DATA ', () => {
ProfilePageActions.load_author_data();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'LOAD_AUTHOR_DATA' });
});
it('Should dispatch REMOVE_PROFILE ', () => {
ProfilePageActions.remove_profile();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'REMOVE_PROFILE' });
});
});
});

View File

@@ -0,0 +1,25 @@
import { SearchFieldActions } from '../../../src/static/js/utils/actions';
import dispatcher from '../../../src/static/js/utils/dispatcher';
// Mock the dispatcher module used by SearchFieldActions
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
describe('utils/actions', () => {
describe('SearchFieldActions', () => {
const dispatch = dispatcher.dispatch;
beforeEach(() => {
(dispatcher.dispatch as jest.Mock).mockClear();
});
describe('requestPredictions', () => {
it('Should dispatch REQUEST_PREDICTIONS with provided query strings', () => {
SearchFieldActions.requestPredictions('cats');
SearchFieldActions.requestPredictions('');
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'REQUEST_PREDICTIONS', query: 'cats' });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'REQUEST_PREDICTIONS', query: '' });
});
});
});
});

View File

@@ -0,0 +1,72 @@
import { VideoViewerActions } from '../../../src/static/js/utils/actions';
import dispatcher from '../../../src/static/js/utils/dispatcher';
// Mock the dispatcher module used by VideoViewerActions
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
describe('utils/actions', () => {
describe('VideoViewerActions', () => {
const dispatch = dispatcher.dispatch;
beforeEach(() => {
(dispatcher.dispatch as jest.Mock).mockClear();
});
describe('set_viewer_mode', () => {
it('Should dispatch SET_VIEWER_MODE with "true" and "false" for enabling and disabling theater mode', () => {
VideoViewerActions.set_viewer_mode(true);
VideoViewerActions.set_viewer_mode(false);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_VIEWER_MODE', inTheaterMode: true });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SET_VIEWER_MODE', inTheaterMode: false });
});
});
describe('set_player_volume', () => {
it('Should dispatch SET_PLAYER_VOLUME with provided volume numbers', () => {
VideoViewerActions.set_player_volume(0);
VideoViewerActions.set_player_volume(0.75);
VideoViewerActions.set_player_volume(1);
expect(dispatch).toHaveBeenCalledTimes(3);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_PLAYER_VOLUME', playerVolume: 0 });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SET_PLAYER_VOLUME', playerVolume: 0.75 });
expect(dispatch).toHaveBeenNthCalledWith(3, { type: 'SET_PLAYER_VOLUME', playerVolume: 1 });
});
});
describe('set_player_sound_muted', () => {
it('Should dispatch SET_PLAYER_SOUND_MUTED with "true" and "false"', () => {
VideoViewerActions.set_player_sound_muted(true);
VideoViewerActions.set_player_sound_muted(false);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_PLAYER_SOUND_MUTED', playerSoundMuted: true });
expect(dispatch).toHaveBeenNthCalledWith(2, {
type: 'SET_PLAYER_SOUND_MUTED',
playerSoundMuted: false,
});
});
});
describe('set_video_quality', () => {
it('Should dispatch SET_VIDEO_QUALITY with "auto" and numeric quality', () => {
VideoViewerActions.set_video_quality('auto');
VideoViewerActions.set_video_quality(720);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_VIDEO_QUALITY', quality: 'auto' });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SET_VIDEO_QUALITY', quality: 720 });
});
});
describe('set_video_playback_speed', () => {
it('Should dispatch SET_VIDEO_PLAYBACK_SPEED with different speeds', () => {
VideoViewerActions.set_video_playback_speed(1.5);
VideoViewerActions.set_video_playback_speed(0.5);
VideoViewerActions.set_video_playback_speed(2);
expect(dispatch).toHaveBeenCalledTimes(3);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_VIDEO_PLAYBACK_SPEED', playbackSpeed: 1.5 });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SET_VIDEO_PLAYBACK_SPEED', playbackSpeed: 0.5 });
expect(dispatch).toHaveBeenNthCalledWith(3, { type: 'SET_VIDEO_PLAYBACK_SPEED', playbackSpeed: 2 });
});
});
});
});

View File

@@ -0,0 +1,92 @@
import { BrowserCache } from '../../../src/static/js/utils/classes/BrowserCache';
// Mocks for helpers used by BrowserCache
jest.mock('../../../src/static/js/utils/helpers/', () => ({
logErrorAndReturnError: jest.fn((args: any[]) => ({ error: true, args })),
logWarningAndReturnError: jest.fn((args: any[]) => ({ warning: true, args })),
}));
const { logErrorAndReturnError } = jest.requireMock('../../../src/static/js/utils/helpers/');
describe('utils/classes', () => {
describe('BrowserCache', () => {
beforeEach(() => {
localStorage.clear();
jest.clearAllMocks();
});
test('Returns error when prefix is missing', () => {
const cache = BrowserCache(undefined, 3600);
expect(cache).toEqual(expect.objectContaining({ error: true }));
expect(logErrorAndReturnError).toHaveBeenCalledWith(['Cache object prefix is required']);
});
test('Set and get returns stored primitive value before expiration', () => {
const cache = BrowserCache('prefix', 3600);
if (cache instanceof Error) {
expect(cache instanceof Error).toBe(false);
return;
}
expect(cache.set('foo', 'bar')).toBe(true);
expect(cache.get('foo')).toBe('bar');
// Ensure value serialized in localStorage with namespaced key
const raw = localStorage.getItem('prefix[foo]') as string;
const parsed = JSON.parse(raw);
expect(parsed.value).toBe('bar');
expect(typeof parsed.expire).toBe('number');
expect(parsed.expire).toBeGreaterThan(Date.now());
});
test('Get returns null when expired', () => {
const cache = BrowserCache('prefix', 1);
if (cache instanceof Error) {
expect(cache instanceof Error).toBe(false);
return;
}
cache.set('exp', { a: 1 });
jest.useFakeTimers();
jest.advanceTimersByTime(1_000);
expect(cache.get('exp')).toBeNull();
jest.useRealTimers();
});
test('Clear removes only keys for its prefix', () => {
const cacheA = BrowserCache('A', 3600);
const cacheB = BrowserCache('B', 3600);
if (cacheA instanceof Error) {
expect(cacheA instanceof Error).toBe(false);
return;
}
if (cacheB instanceof Error) {
expect(cacheB instanceof Error).toBe(false);
return;
}
cacheA.set('x', 1);
cacheB.set('x', 2);
expect(localStorage.getItem('A[x]')).toBeTruthy();
expect(localStorage.getItem('B[x]')).toBeTruthy();
cacheA.clear();
expect(localStorage.getItem('A[x]')).toBeNull();
expect(localStorage.getItem('B[x]')).toBeTruthy();
cacheB.clear();
expect(localStorage.getItem('A[x]')).toBeNull();
expect(localStorage.getItem('B[x]')).toBeNull();
});
});
});

View File

@@ -0,0 +1,101 @@
import { MediaDurationInfo } from '../../../src/static/js/utils/classes/MediaDurationInfo';
describe('utils/classes', () => {
describe('MediaDurationInfo', () => {
test('Initializes via constructor when seconds is a positive integer (<= 59)', () => {
const mdi = new MediaDurationInfo(42);
expect(mdi.toString()).toBe('0:42');
expect(mdi.ariaLabel()).toBe('42 seconds');
expect(mdi.ISO8601()).toBe('P0Y0M0DT0H0M42S');
});
test('Formats minutes and zero-pads seconds; no hours prefix under 60 minutes', () => {
const mdi = new MediaDurationInfo();
mdi.update(5 * 60 + 7);
expect(mdi.toString()).toBe('5:07');
expect(mdi.ariaLabel()).toBe('5 minutes, 7 seconds');
expect(mdi.ISO8601()).toBe('P0Y0M0DT0H5M7S');
});
test('Includes hours when duration >= 1 hour and zero-pads minutes when needed', () => {
const mdi = new MediaDurationInfo();
mdi.update(1 * 3600 + 2 * 60 + 3);
expect(mdi.toString()).toBe('1:02:03');
expect(mdi.ariaLabel()).toBe('1 hours, 2 minutes, 3 seconds');
expect(mdi.ISO8601()).toBe('P0Y0M0DT1H2M3S');
});
test('Accumulates hours when days are present (e.g., 1 day + 2:03:04 => 26:03:04)', () => {
const mdi = new MediaDurationInfo();
const seconds = 1 * 86400 + 2 * 3600 + 3 * 60 + 4; // 1d 2:03:04 => 26:03:04
mdi.update(seconds);
expect(mdi.toString()).toBe('26:03:04');
expect(mdi.ariaLabel()).toBe('26 hours, 3 minutes, 4 seconds');
expect(mdi.ISO8601()).toBe('P0Y0M0DT26H3M4S');
});
test('Large durations: multiple days correctly mapped into hours', () => {
const mdi = new MediaDurationInfo();
const seconds = 3 * 86400 + 10 * 3600 + 15 * 60 + 9; // 3d 10:15:09 => 82:15:09
mdi.update(seconds);
expect(mdi.toString()).toBe('82:15:09');
expect(mdi.ariaLabel()).toBe('82 hours, 15 minutes, 9 seconds');
expect(mdi.ISO8601()).toBe('P0Y0M0DT82H15M9S');
});
test('Caching: toString and ariaLabel recompute only after update()', () => {
const mdi = new MediaDurationInfo(59);
const firstToString = mdi.toString();
const firstAria = mdi.ariaLabel();
expect(firstToString).toBe('0:59');
expect(firstAria).toBe('59 seconds');
// Call again to hit cached path
expect(mdi.toString()).toBe(firstToString);
expect(mdi.ariaLabel()).toBe(firstAria);
// Update and ensure cache invalidates
mdi.update(60);
expect(mdi.toString()).toBe('1:00');
expect(mdi.ariaLabel()).toBe('1 minutes');
});
test('Ignores invalid (non-positive integer or zero) updates, retaining previous value', () => {
const mdi = new MediaDurationInfo(10);
expect(mdi.toString()).toBe('0:10');
mdi.update(1.23);
expect(mdi.toString()).toBe('0:10');
mdi.update(-5);
expect(mdi.toString()).toBe('0:10');
mdi.update('x');
expect(mdi.toString()).toBe('0:10');
});
test('Boundary conditions around a minute and an hour', () => {
const mdi = new MediaDurationInfo();
mdi.update(59);
expect(mdi.toString()).toBe('0:59');
mdi.update(60);
expect(mdi.toString()).toBe('1:00');
mdi.update(3599);
expect(mdi.toString()).toBe('59:59');
mdi.update(3600);
expect(mdi.toString()).toBe('1:00:00');
});
// @todo: Revisit this behavior
test('Constructs without initial seconds', () => {
const mdi = new MediaDurationInfo();
expect(typeof mdi.toString()).toBe('function');
expect(mdi.ariaLabel()).toBe('');
expect(mdi.ISO8601()).toBe('P0Y0M0DTundefinedHundefinedMundefinedS');
});
});
});

View File

@@ -0,0 +1,102 @@
import { UpNextLoaderView } from '../../../src/static/js/utils/classes/UpNextLoaderView';
// Minimal helpers mocks used by UpNextLoaderView
jest.mock('../../../src/static/js/utils/helpers/', () => ({
addClassname: jest.fn((el: any, cn: string) => el && el.classList && el.classList.add(cn)),
removeClassname: jest.fn((el: any, cn: string) => el && el.classList && el.classList.remove(cn)),
translateString: (s: string) => s,
}));
const { addClassname, removeClassname } = jest.requireMock('../../../src/static/js/utils/helpers/');
const makeNextItem = () => ({
url: '/next-url',
title: 'Next title',
author_name: 'Jane Doe',
thumbnail_url: 'https://example.com/thumb.jpg',
});
describe('utils/classes', () => {
describe('UpNextLoaderView', () => {
test('html() builds structure with expected classes and content', () => {
const v = new UpNextLoaderView(makeNextItem());
const root = v.html();
expect(root).toBeInstanceOf(HTMLElement);
expect(root.querySelector('.up-next-loader-inner')).not.toBeNull();
expect(root.querySelector('.up-next-label')!.textContent).toBe('Up Next');
expect(root.querySelector('.next-media-title')!.textContent).toBe('Next title');
expect(root.querySelector('.next-media-author')!.textContent).toBe('Jane Doe');
// poster background
const poster = root.querySelector('.next-media-poster') as HTMLElement;
expect(poster.style.backgroundImage).toContain('thumb.jpg');
// go-next link points to next url
const link = root.querySelector('.go-next a') as HTMLAnchorElement;
expect(link.getAttribute('href')).toBe('/next-url');
});
test('setVideoJsPlayerElem marks player with vjs-mediacms-has-up-next-view class', () => {
const v = new UpNextLoaderView(makeNextItem());
const player = document.createElement('div');
v.setVideoJsPlayerElem(player);
expect(addClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-has-up-next-view');
expect(v.vjsPlayerElem).toBe(player);
});
test('startTimer shows view, registers scroll, and navigates after 10s', () => {
const next = makeNextItem();
const v = new UpNextLoaderView(next);
const player = document.createElement('div');
v.setVideoJsPlayerElem(player);
v.startTimer();
expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-up-next-hidden');
expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next');
});
test('cancelTimer clears timeout, stops scroll, and marks canceled', () => {
const v = new UpNextLoaderView(makeNextItem());
const player = document.createElement('div');
v.setVideoJsPlayerElem(player);
v.startTimer();
v.cancelTimer();
expect(addClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next');
});
test('Cancel button click hides the view and cancels timer', () => {
const v = new UpNextLoaderView(makeNextItem());
const player = document.createElement('div');
v.setVideoJsPlayerElem(player);
v.startTimer();
const root = v.html();
const cancelBtn = root.querySelector('.up-next-cancel button') as HTMLButtonElement;
cancelBtn.click();
expect(addClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next');
});
test('showTimerView shows or starts timer based on flag', () => {
const v = new UpNextLoaderView(makeNextItem());
const player = document.createElement('div');
v.setVideoJsPlayerElem(player);
// beginTimer=false -> just show view
v.showTimerView(false);
expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-up-next-hidden');
// beginTimer=true -> starts timer
v.showTimerView(true);
expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next');
});
});
});

View File

@@ -0,0 +1,749 @@
import React from 'react';
import { render, fireEvent, act } from '@testing-library/react';
import { useBulkActions } from '../../../src/static/js/utils/hooks/useBulkActions';
// Mock translateString to return the input for easier assertions
jest.mock('../../../src/static/js/utils/helpers', () => ({
translateString: (s: string) => s,
}));
// Component that exposes hook state/handlers to DOM for testing
function HookConsumer() {
const hook = useBulkActions();
return (
<div>
<div data-testid="selected-count">{Array.from(hook.selectedMedia).length}</div>
<div data-testid="available-count">{hook.availableMediaIds.length}</div>
<div data-testid="show-confirm">{String(hook.showConfirmModal)}</div>
<div data-testid="confirm-message">{hook.confirmMessage}</div>
<div data-testid="list-key">{hook.listKey}</div>
<div data-testid="notification-message">{hook.notificationMessage}</div>
<div data-testid="show-notification">{String(hook.showNotification)}</div>
{/* @todo: It doesn't used */}
{/* <div data-testid="notification-type">{hook.notificationType}</div> */}
<div data-testid="show-permission">{String(hook.showPermissionModal)}</div>
<div data-testid="permission-type">{hook.permissionType || ''}</div>
<div data-testid="show-playlist">{String(hook.showPlaylistModal)}</div>
<div data-testid="show-change-owner">{String(hook.showChangeOwnerModal)}</div>
<div data-testid="show-publish-state">{String(hook.showPublishStateModal)}</div>
<div data-testid="show-category">{String(hook.showCategoryModal)}</div>
<div data-testid="show-tag">{String(hook.showTagModal)}</div>
<button data-testid="btn-handle-media-select" onClick={() => hook.handleMediaSelection('m1', true)} />
<button data-testid="btn-handle-media-deselect" onClick={() => hook.handleMediaSelection('m1', false)} />
<button
data-testid="btn-handle-items-update"
onClick={() => hook.handleItemsUpdate([{ id: 'a' }, { uid: 'b' }, { friendly_token: 'c' }])}
/>
<button data-testid="btn-select-all" onClick={() => hook.handleSelectAll()} />
<button data-testid="btn-deselect-all" onClick={() => hook.handleDeselectAll()} />
<button data-testid="btn-clear-selection" onClick={() => hook.clearSelection()} />
<button data-testid="btn-clear-refresh" onClick={() => hook.clearSelectionAndRefresh()} />
<button data-testid="btn-bulk-delete" onClick={() => hook.handleBulkAction('delete-media')} />
<button data-testid="btn-bulk-enable-comments" onClick={() => hook.handleBulkAction('enable-comments')} />
<button data-testid="btn-bulk-disable-comments" onClick={() => hook.handleBulkAction('disable-comments')} />
<button data-testid="btn-bulk-enable-download" onClick={() => hook.handleBulkAction('enable-download')} />
<button data-testid="btn-bulk-disable-download" onClick={() => hook.handleBulkAction('disable-download')} />
<button data-testid="btn-bulk-copy" onClick={() => hook.handleBulkAction('copy-media')} />
<button data-testid="btn-bulk-perm-viewer" onClick={() => hook.handleBulkAction('add-remove-coviewers')} />
<button data-testid="btn-bulk-perm-editor" onClick={() => hook.handleBulkAction('add-remove-coeditors')} />
<button data-testid="btn-bulk-perm-owner" onClick={() => hook.handleBulkAction('add-remove-coowners')} />
<button data-testid="btn-bulk-playlist" onClick={() => hook.handleBulkAction('add-remove-playlist')} />
<button data-testid="btn-bulk-change-owner" onClick={() => hook.handleBulkAction('change-owner')} />
<button data-testid="btn-bulk-publish" onClick={() => hook.handleBulkAction('publish-state')} />
<button data-testid="btn-bulk-category" onClick={() => hook.handleBulkAction('add-remove-category')} />
<button data-testid="btn-bulk-tag" onClick={() => hook.handleBulkAction('add-remove-tags')} />
<button data-testid="btn-bulk-unknown" onClick={() => hook.handleBulkAction('unknown-action')} />
<button data-testid="btn-confirm-proceed" onClick={() => hook.handleConfirmProceed()} />
<button data-testid="btn-confirm-cancel" onClick={() => hook.handleConfirmCancel()} />
<button data-testid="btn-perm-cancel" onClick={() => hook.handlePermissionModalCancel()} />
<button data-testid="btn-perm-success" onClick={() => hook.handlePermissionModalSuccess('perm ok')} />
<button data-testid="btn-perm-error" onClick={() => hook.handlePermissionModalError('perm err')} />
<button data-testid="btn-playlist-cancel" onClick={() => hook.handlePlaylistModalCancel()} />
<button data-testid="btn-playlist-success" onClick={() => hook.handlePlaylistModalSuccess('pl ok')} />
<button data-testid="btn-playlist-error" onClick={() => hook.handlePlaylistModalError('pl err')} />
<button data-testid="btn-change-owner-cancel" onClick={() => hook.handleChangeOwnerModalCancel()} />
<button
data-testid="btn-change-owner-success"
onClick={() => hook.handleChangeOwnerModalSuccess('owner ok')}
/>
<button
data-testid="btn-change-owner-error"
onClick={() => hook.handleChangeOwnerModalError('owner err')}
/>
<button data-testid="btn-publish-cancel" onClick={() => hook.handlePublishStateModalCancel()} />
<button data-testid="btn-publish-success" onClick={() => hook.handlePublishStateModalSuccess('pub ok')} />
<button data-testid="btn-publish-error" onClick={() => hook.handlePublishStateModalError('pub err')} />
<button data-testid="btn-category-cancel" onClick={() => hook.handleCategoryModalCancel()} />
<button data-testid="btn-category-success" onClick={() => hook.handleCategoryModalSuccess('cat ok')} />
<button data-testid="btn-category-error" onClick={() => hook.handleCategoryModalError('cat err')} />
<button data-testid="btn-tag-cancel" onClick={() => hook.handleTagModalCancel()} />
<button data-testid="btn-tag-success" onClick={() => hook.handleTagModalSuccess('tag ok')} />
<button data-testid="btn-tag-error" onClick={() => hook.handleTagModalError('tag err')} />
<div data-testid="csrf">{String(hook.getCsrfToken())}</div>
</div>
);
}
describe('useBulkActions', () => {
beforeEach(() => {
jest.clearAllMocks();
document.cookie.split(';').forEach((c) => {
document.cookie = c.replace(/^ +/, '').replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/');
});
global.fetch = jest.fn();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('Utility Functions', () => {
test('getCsrfToken reads csrftoken from cookies', () => {
document.cookie = 'csrftoken=abc123';
const { getByTestId } = render(<HookConsumer />);
expect(getByTestId('csrf').textContent).toBe('abc123');
});
test('getCsrfToken returns null when csrftoken is not present', () => {
// No cookie set, should return null
const { getByTestId } = render(<HookConsumer />);
expect(getByTestId('csrf').textContent).toBe('null');
});
test('getCsrfToken returns null when document.cookie is empty', () => {
// Even if we try to set empty cookie, it should return null if no csrftoken
const { getByTestId } = render(<HookConsumer />);
expect(getByTestId('csrf').textContent).toBe('null');
});
});
describe('Selection Management', () => {
test('handleMediaSelection toggles selected media', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
expect(getByTestId('selected-count').textContent).toBe('1');
fireEvent.click(getByTestId('btn-handle-media-deselect'));
expect(getByTestId('selected-count').textContent).toBe('0');
});
test('handleItemsUpdate extracts ids correctly from items with different id types', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-items-update'));
expect(getByTestId('available-count').textContent).toBe('3');
});
test('handleSelectAll selects all available items', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-items-update'));
fireEvent.click(getByTestId('btn-select-all'));
expect(getByTestId('selected-count').textContent).toBe('3');
});
test('handleDeselectAll deselects all items', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-items-update'));
fireEvent.click(getByTestId('btn-select-all'));
fireEvent.click(getByTestId('btn-deselect-all'));
expect(getByTestId('selected-count').textContent).toBe('0');
});
test('clearSelection clears all selected media', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
expect(getByTestId('selected-count').textContent).toBe('1');
fireEvent.click(getByTestId('btn-clear-selection'));
expect(getByTestId('selected-count').textContent).toBe('0');
});
test('clearSelectionAndRefresh clears selection and increments listKey', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-items-update'));
fireEvent.click(getByTestId('btn-select-all'));
expect(getByTestId('list-key').textContent).toBe('0');
fireEvent.click(getByTestId('btn-clear-refresh'));
expect(getByTestId('selected-count').textContent).toBe('0');
expect(getByTestId('list-key').textContent).toBe('1');
});
});
describe('Bulk Actions - Modal Opening', () => {
test('handleBulkAction does nothing when no selection', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-bulk-delete'));
expect(getByTestId('show-confirm').textContent).toBe('false');
});
test('handleBulkAction opens confirm modal for delete, enable/disable comments and download, copy', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-delete'));
expect(getByTestId('show-confirm').textContent).toBe('true');
fireEvent.click(getByTestId('btn-bulk-enable-comments'));
expect(getByTestId('show-confirm').textContent).toBe('true');
fireEvent.click(getByTestId('btn-bulk-disable-comments'));
expect(getByTestId('show-confirm').textContent).toBe('true');
fireEvent.click(getByTestId('btn-bulk-enable-download'));
expect(getByTestId('show-confirm').textContent).toBe('true');
fireEvent.click(getByTestId('btn-bulk-disable-download'));
expect(getByTestId('show-confirm').textContent).toBe('true');
fireEvent.click(getByTestId('btn-bulk-copy'));
expect(getByTestId('show-confirm').textContent).toBe('true');
});
test('handleBulkAction opens permission modals with correct types', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-perm-viewer'));
expect(getByTestId('show-permission').textContent).toBe('true');
expect(getByTestId('permission-type').textContent).toBe('viewer');
fireEvent.click(getByTestId('btn-bulk-perm-editor'));
expect(getByTestId('permission-type').textContent).toBe('editor');
fireEvent.click(getByTestId('btn-bulk-perm-owner'));
expect(getByTestId('permission-type').textContent).toBe('owner');
});
test('handleBulkAction opens other modals', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-playlist'));
expect(getByTestId('show-playlist').textContent).toBe('true');
fireEvent.click(getByTestId('btn-bulk-change-owner'));
expect(getByTestId('show-change-owner').textContent).toBe('true');
fireEvent.click(getByTestId('btn-bulk-publish'));
expect(getByTestId('show-publish-state').textContent).toBe('true');
fireEvent.click(getByTestId('btn-bulk-category'));
expect(getByTestId('show-category').textContent).toBe('true');
fireEvent.click(getByTestId('btn-bulk-tag'));
expect(getByTestId('show-tag').textContent).toBe('true');
});
test('handleBulkAction with unknown action does nothing', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-unknown'));
expect(getByTestId('show-confirm').textContent).toBe('false');
expect(getByTestId('show-permission').textContent).toBe('false');
});
});
describe('Confirm Modal Handlers', () => {
test('handleConfirmCancel closes confirm modal and resets state', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-delete'));
expect(getByTestId('show-confirm').textContent).toBe('true');
fireEvent.click(getByTestId('btn-confirm-cancel'));
expect(getByTestId('show-confirm').textContent).toBe('false');
expect(getByTestId('confirm-message').textContent).toBe('');
});
});
describe('Delete Media Execution', () => {
test('executeDeleteMedia success with notification', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-delete'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('The media was deleted successfully');
expect(getByTestId('show-notification').textContent).toBe('true');
act(() => {
jest.advanceTimersByTime(5000);
});
expect(getByTestId('show-notification').textContent).toBe('false');
});
test('executeDeleteMedia handles response.ok = false', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-delete'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Failed to delete media');
});
test('executeDeleteMedia handles fetch rejection exception', async () => {
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-delete'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Failed to delete media');
expect(getByTestId('selected-count').textContent).toBe('0');
});
});
describe('Comments Management Execution', () => {
test('executeEnableComments success', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-enable-comments'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Successfully Enabled comments');
});
test('executeEnableComments handles response.ok = false', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-enable-comments'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Failed to enable comments');
});
test('executeEnableComments handles fetch rejection exception', async () => {
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-enable-comments'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Failed to enable comments');
expect(getByTestId('selected-count').textContent).toBe('0');
});
test('executeDisableComments success', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-disable-comments'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Successfully Disabled comments');
expect(getByTestId('selected-count').textContent).toBe('0');
});
test('executeDisableComments handles response.ok = false', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-disable-comments'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Failed to disable comments');
});
test('executeDisableComments handles fetch rejection exception', async () => {
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-disable-comments'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Failed to disable comments');
expect(getByTestId('selected-count').textContent).toBe('0');
});
});
describe('Download Management Execution', () => {
test('executeEnableDownload success', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-enable-download'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Successfully Enabled Download');
});
test('executeEnableDownload handles response.ok = false', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-enable-download'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Failed to enable download');
});
test('executeEnableDownload handles fetch rejection exception', async () => {
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-enable-download'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Failed to enable download');
expect(getByTestId('selected-count').textContent).toBe('0');
});
test('executeDisableDownload success', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-disable-download'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Successfully Disabled Download');
expect(getByTestId('selected-count').textContent).toBe('0');
});
test('executeDisableDownload handles response.ok = false', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-disable-download'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Failed to disable download');
});
test('executeDisableDownload handles fetch rejection exception', async () => {
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-disable-download'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Failed to disable download');
expect(getByTestId('selected-count').textContent).toBe('0');
});
});
describe('Copy Media Execution', () => {
test('executeCopyMedia success', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-copy'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Successfully Copied');
});
test('executeCopyMedia handles response.ok = false', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-copy'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Failed to copy media');
});
test('executeCopyMedia handles fetch rejection exception', async () => {
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-copy'));
await act(async () => {
fireEvent.click(getByTestId('btn-confirm-proceed'));
await Promise.resolve();
});
expect(getByTestId('notification-message').textContent).toContain('Failed to copy media');
expect(getByTestId('selected-count').textContent).toBe('0');
});
});
describe('Permission Modal Handlers', () => {
test('handlePermissionModalCancel closes permission modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-perm-viewer'));
expect(getByTestId('show-permission').textContent).toBe('true');
fireEvent.click(getByTestId('btn-perm-cancel'));
expect(getByTestId('show-permission').textContent).toBe('false');
expect(getByTestId('permission-type').textContent).toBe('');
});
test('handlePermissionModalSuccess shows notification and closes modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-perm-success'));
expect(getByTestId('notification-message').textContent).toBe('perm ok');
expect(getByTestId('show-permission').textContent).toBe('false');
});
test('handlePermissionModalError shows error notification and closes modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-perm-error'));
expect(getByTestId('notification-message').textContent).toBe('perm err');
expect(getByTestId('show-permission').textContent).toBe('false');
});
});
describe('Playlist Modal Handlers', () => {
test('handlePlaylistModalCancel closes playlist modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-playlist'));
expect(getByTestId('show-playlist').textContent).toBe('true');
fireEvent.click(getByTestId('btn-playlist-cancel'));
expect(getByTestId('show-playlist').textContent).toBe('false');
});
test('handlePlaylistModalSuccess shows notification and closes modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-playlist-success'));
expect(getByTestId('notification-message').textContent).toBe('pl ok');
expect(getByTestId('show-playlist').textContent).toBe('false');
});
test('handlePlaylistModalError shows error notification and closes modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-playlist-error'));
expect(getByTestId('notification-message').textContent).toBe('pl err');
expect(getByTestId('show-playlist').textContent).toBe('false');
});
});
describe('Change Owner Modal Handlers', () => {
test('handleChangeOwnerModalCancel closes change owner modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-change-owner'));
expect(getByTestId('show-change-owner').textContent).toBe('true');
fireEvent.click(getByTestId('btn-change-owner-cancel'));
expect(getByTestId('show-change-owner').textContent).toBe('false');
});
test('handleChangeOwnerModalSuccess shows notification and closes modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-change-owner-success'));
expect(getByTestId('notification-message').textContent).toBe('owner ok');
expect(getByTestId('show-change-owner').textContent).toBe('false');
});
test('handleChangeOwnerModalError shows error notification and closes modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-change-owner-error'));
expect(getByTestId('notification-message').textContent).toBe('owner err');
expect(getByTestId('show-change-owner').textContent).toBe('false');
});
});
describe('Publish State Modal Handlers', () => {
test('handlePublishStateModalCancel closes publish state modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-publish'));
expect(getByTestId('show-publish-state').textContent).toBe('true');
fireEvent.click(getByTestId('btn-publish-cancel'));
expect(getByTestId('show-publish-state').textContent).toBe('false');
});
test('handlePublishStateModalSuccess shows notification and closes modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-publish-success'));
expect(getByTestId('notification-message').textContent).toBe('pub ok');
expect(getByTestId('show-publish-state').textContent).toBe('false');
});
test('handlePublishStateModalError shows error notification and closes modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-publish-error'));
expect(getByTestId('notification-message').textContent).toBe('pub err');
expect(getByTestId('show-publish-state').textContent).toBe('false');
});
});
describe('Category Modal Handlers', () => {
test('handleCategoryModalCancel closes category modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-category'));
expect(getByTestId('show-category').textContent).toBe('true');
fireEvent.click(getByTestId('btn-category-cancel'));
expect(getByTestId('show-category').textContent).toBe('false');
});
test('handleCategoryModalSuccess shows notification and closes modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-category-success'));
expect(getByTestId('notification-message').textContent).toBe('cat ok');
expect(getByTestId('show-category').textContent).toBe('false');
});
test('handleCategoryModalError shows error notification and closes modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-category-error'));
expect(getByTestId('notification-message').textContent).toBe('cat err');
expect(getByTestId('show-category').textContent).toBe('false');
});
});
describe('Tag Modal Handlers', () => {
test('handleTagModalCancel closes tag modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-handle-media-select'));
fireEvent.click(getByTestId('btn-bulk-tag'));
expect(getByTestId('show-tag').textContent).toBe('true');
fireEvent.click(getByTestId('btn-tag-cancel'));
expect(getByTestId('show-tag').textContent).toBe('false');
});
test('handleTagModalSuccess shows notification and closes modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-tag-success'));
expect(getByTestId('notification-message').textContent).toBe('tag ok');
expect(getByTestId('show-tag').textContent).toBe('false');
});
test('handleTagModalError shows error notification and closes modal', () => {
const { getByTestId } = render(<HookConsumer />);
fireEvent.click(getByTestId('btn-tag-error'));
expect(getByTestId('notification-message').textContent).toBe('tag err');
expect(getByTestId('show-tag').textContent).toBe('false');
});
});
});

View File

@@ -0,0 +1,380 @@
import React from 'react';
import { render } from '@testing-library/react';
import { useItem } from '../../../src/static/js/utils/hooks/useItem';
// Mock the item components
jest.mock('../../../src/static/js/components/list-item/includes/items', () => ({
ItemDescription: ({ description }: { description: string }) => (
<div data-testid="item-description">{description}</div>
),
ItemMain: ({ children }: { children: React.ReactNode }) => <div data-testid="item-main">{children}</div>,
ItemMainInLink: ({ children, link, title }: { children: React.ReactNode; link: string; title: string }) => (
<div data-testid="item-main-in-link" data-link={link} data-title={title}>
{children}
</div>
),
ItemTitle: ({ title, ariaLabel }: { title: string; ariaLabel: string }) => (
<h3 data-testid="item-title" data-aria-label={ariaLabel}>
{title}
</h3>
),
ItemTitleLink: ({ title, link, ariaLabel }: { title: string; link: string; ariaLabel: string }) => (
<h3 data-testid="item-title-link" data-link={link} data-aria-label={ariaLabel}>
{title}
</h3>
),
}));
// Mock PageStore
jest.mock('../../../src/static/js/utils/stores/PageStore.js', () => ({
__esModule: true,
default: {
get: (key: string) => (key === 'config-site' ? { url: 'https://example.com' } : null),
},
}));
// HookConsumer component to test the hook
function HookConsumer(props: any) {
const { titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper } = useItem(props);
return (
<div>
<div data-testid="title">{titleComponent()}</div>
<div data-testid="description">{descriptionComponent()}</div>
<div data-testid="thumbnail-url">{thumbnailUrl || 'null'}</div>
<div data-testid="wrapper-type">{(UnderThumbWrapper as any).name}</div>
<div data-testid="wrapper-component">
<div>Wrapper content</div>
</div>
</div>
);
}
// Wrapper consumer to test wrapper selection
function WrapperTest(props: any) {
const { UnderThumbWrapper } = useItem(props);
return (
<UnderThumbWrapper link={props.link} title={props.title} data-testid="wrapper-test">
<span data-testid="wrapper-content">Content</span>
</UnderThumbWrapper>
);
}
describe('utils/hooks', () => {
describe('useItem', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('titleComponent Rendering', () => {
test('Renders ItemTitle when singleLinkContent is true', () => {
const { getByTestId } = render(
<HookConsumer
title="Test Title"
description="Test Description"
link="https://example.com"
thumbnail=""
singleLinkContent={true}
/>
);
expect(getByTestId('title').querySelector('[data-testid="item-title"]')).toBeTruthy();
expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeFalsy();
});
test('Renders ItemTitleLink when singleLinkContent is false', () => {
const { getByTestId } = render(
<HookConsumer
title="Test Title"
description="Test Description"
link="https://example.com"
thumbnail=""
singleLinkContent={false}
/>
);
expect(getByTestId('title').querySelector('[data-testid="item-title"]')).toBeFalsy();
expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeTruthy();
});
test('Renders with default link when singleLinkContent is not provided', () => {
const { getByTestId } = render(
<HookConsumer title="Test Title" description="Test Description" link="/media/test" thumbnail="" />
);
// Default is false for singleLinkContent
expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeTruthy();
});
});
describe('descriptionComponent Rendering', () => {
test('Renders single ItemDescription when hasMediaViewer is false', () => {
const { getByTestId, queryAllByTestId } = render(
<HookConsumer
title="Test Title"
description="My Description"
link="https://example.com"
thumbnail=""
hasMediaViewer={false}
/>
);
const descriptions = queryAllByTestId('item-description');
expect(descriptions.length).toBe(1);
expect(descriptions[0].textContent).toBe('My Description');
});
test('Renders single ItemDescription when hasMediaViewerDescr is false', () => {
const { getByTestId, queryAllByTestId } = render(
<HookConsumer
title="Test Title"
description="My Description"
link="https://example.com"
thumbnail=""
hasMediaViewer={true}
hasMediaViewerDescr={false}
/>
);
const descriptions = queryAllByTestId('item-description');
expect(descriptions.length).toBe(1);
expect(descriptions[0].textContent).toBe('My Description');
});
test('Renders two ItemDescriptions when hasMediaViewer and hasMediaViewerDescr are both true', () => {
const { queryAllByTestId } = render(
<HookConsumer
title="Test Title"
description="Main Description"
link="https://example.com"
thumbnail=""
hasMediaViewer={true}
hasMediaViewerDescr={true}
meta_description="Meta Description"
/>
);
const descriptions = queryAllByTestId('item-description');
expect(descriptions.length).toBe(2);
expect(descriptions[0].textContent).toBe('Meta Description');
expect(descriptions[1].textContent).toBe('Main Description');
});
test('Trims description text', () => {
const { queryAllByTestId } = render(
<HookConsumer
title="Test Title"
description=" Description with spaces "
link="https://example.com"
thumbnail=""
/>
);
expect(queryAllByTestId('item-description')[0].textContent).toBe('Description with spaces');
});
test('Trims meta_description text', () => {
const { queryAllByTestId } = render(
<HookConsumer
title="Test Title"
description="Main Description"
link="https://example.com"
thumbnail=""
hasMediaViewer={true}
hasMediaViewerDescr={true}
meta_description=" Meta with spaces "
/>
);
expect(queryAllByTestId('item-description')[0].textContent).toBe('Meta with spaces');
});
});
describe('thumbnailUrl', () => {
test('Returns null when thumbnail is empty string', () => {
const { getByTestId } = render(
<HookConsumer
title="Test Title"
description="Test Description"
link="https://example.com"
thumbnail=""
/>
);
expect(getByTestId('thumbnail-url').textContent).toBe('null');
});
test('Returns formatted URL when thumbnail has value', () => {
const { getByTestId } = render(
<HookConsumer
title="Test Title"
description="Test Description"
link="https://example.com"
thumbnail="/media/thumbnail.jpg"
/>
);
expect(getByTestId('thumbnail-url').textContent).toBe('https://example.com/media/thumbnail.jpg');
});
test('Handles absolute URLs as thumbnails', () => {
const { getByTestId } = render(
<HookConsumer
title="Test Title"
description="Test Description"
link="https://example.com"
thumbnail="https://cdn.example.com/image.jpg"
/>
);
// formatInnerLink should preserve absolute URLs
expect(getByTestId('thumbnail-url').textContent).toBe('https://cdn.example.com/image.jpg');
});
});
describe('UnderThumbWrapper', () => {
test('Uses ItemMainInLink when singleLinkContent is true', () => {
const { getByTestId } = render(
<WrapperTest
title="Test Title"
description="Test Description"
link="https://example.com"
thumbnail=""
singleLinkContent={true}
/>
);
// When singleLinkContent is true, UnderThumbWrapper should be ItemMainInLink
expect(getByTestId('item-main-in-link')).toBeTruthy();
expect(getByTestId('item-main-in-link').getAttribute('data-link')).toBe('https://example.com');
expect(getByTestId('item-main-in-link').getAttribute('data-title')).toBe('Test Title');
});
test('Uses ItemMain when singleLinkContent is false', () => {
const { getByTestId } = render(
<WrapperTest
title="Test Title"
description="Test Description"
link="https://example.com"
thumbnail=""
singleLinkContent={false}
/>
);
// When singleLinkContent is false, UnderThumbWrapper should be ItemMain
expect(getByTestId('item-main')).toBeTruthy();
});
test('Uses ItemMain by default when singleLinkContent is not provided', () => {
const { getByTestId } = render(
<WrapperTest
title="Test Title"
description="Test Description"
link="https://example.com"
thumbnail=""
/>
);
// Default is singleLinkContent=false, so ItemMain
expect(getByTestId('item-main')).toBeTruthy();
});
});
describe('onMount callback', () => {
test('Calls onMount callback when component mounts', () => {
const onMountCallback = jest.fn();
render(
<HookConsumer
title="Test Title"
description="Test Description"
link="https://example.com"
thumbnail=""
onMount={onMountCallback}
/>
);
expect(onMountCallback).toHaveBeenCalledTimes(1);
});
test('Calls onMount only once on initial mount', () => {
const onMountCallback = jest.fn();
const { rerender } = render(
<HookConsumer
title="Test Title"
description="Test Description"
link="https://example.com"
thumbnail=""
onMount={onMountCallback}
/>
);
expect(onMountCallback).toHaveBeenCalledTimes(1);
rerender(
<HookConsumer
title="Updated Title"
description="Updated Description"
link="https://example.com"
thumbnail=""
onMount={onMountCallback}
/>
);
// Should still be called only once (useEffect with empty dependency array)
expect(onMountCallback).toHaveBeenCalledTimes(1);
});
});
describe('Integration tests', () => {
test('Complete rendering with all props', () => {
const onMount = jest.fn();
const { getByTestId, queryAllByTestId } = render(
<HookConsumer
title="Complete Test"
description="Complete Description"
link="/media/complete"
thumbnail="/img/thumb.jpg"
type="media"
hasMediaViewer={true}
hasMediaViewerDescr={true}
meta_description="Complete Meta"
singleLinkContent={false}
onMount={onMount}
/>
);
const descriptions = queryAllByTestId('item-description');
expect(descriptions.length).toBe(2);
expect(onMount).toHaveBeenCalledTimes(1);
expect(getByTestId('thumbnail-url').textContent).toBe('https://example.com/img/thumb.jpg');
});
test('Minimal props required', () => {
const { getByTestId } = render(
<HookConsumer title="Title" description="Description" link="/link" thumbnail="" />
);
expect(getByTestId('title')).toBeTruthy();
expect(getByTestId('description')).toBeTruthy();
expect(getByTestId('thumbnail-url').textContent).toBe('null');
});
test('Renders with special characters in title and description', () => {
const { queryAllByTestId } = render(
<HookConsumer
title="Title with & < > special chars"
description={`Description with 'quotes' and "double quotes"`}
link="/media"
thumbnail=""
/>
);
const descriptions = queryAllByTestId('item-description');
expect(descriptions[0].textContent).toContain('Description with');
});
});
});
});

View File

@@ -0,0 +1,124 @@
import React, { createRef } from 'react';
import { render } from '@testing-library/react';
// Stub style imports used by the hook so Jest doesn't try to parse SCSS
jest.mock('../../../src/static/js/components/item-list/ItemList.scss', () => ({}), { virtual: true });
jest.mock('../../../src/static/js/components/item-list/includes/itemLists/initItemsList', () => ({
__esModule: true,
default: jest.fn((_lists: any[]) => [{ appendItems: jest.fn() }]),
}));
import initItemsList from '../../../src/static/js/components/item-list/includes/itemLists/initItemsList';
import { useItemList } from '../../../src/static/js/utils/hooks/useItemList';
function HookConsumer(props: any) {
const listRef = createRef<HTMLDivElement>();
const [items, countedItems, listHandler, setListHandler, onItemsLoad, onItemsCount, addListItems] = useItemList(
props,
listRef
) as any[];
return (
<div>
<div ref={listRef} data-testid="list" className="list">
{(items as any[]).map((_, idx) => (
<div key={idx} className="item" data-testid={`itm-${idx}`} />
))}
</div>
<div data-testid="counted">{String(countedItems)}</div>
<div data-testid="len">{items.length}</div>
<button data-testid="load-call" onClick={() => onItemsLoad([1, 2])} />
<button data-testid="count-call" onClick={() => onItemsCount(5)} />
<button data-testid="add-call" onClick={() => addListItems()} />
<button data-testid="set-handler" onClick={() => setListHandler({ foo: 'bar' })} />
<div data-testid="has-handler">{listHandler ? 'yes' : 'no'}</div>
</div>
);
}
describe('utils/hooks', () => {
describe('useItemList', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('Initial state: empty items and not counted', () => {
const { getByTestId } = render(<HookConsumer />);
expect(getByTestId('counted').textContent).toBe('false');
expect(getByTestId('len').textContent).toBe('0');
expect(getByTestId('has-handler').textContent).toBe('no');
});
test('onItemsLoad updates items and renders item nodes', () => {
const { getByTestId, getByTestId: $ } = render(<HookConsumer />);
(getByTestId('load-call') as HTMLButtonElement).click();
expect(getByTestId('len').textContent).toBe('2');
expect($('itm-0')).toBeTruthy();
expect($('itm-1')).toBeTruthy();
});
test('onItemsCount marks countedItems true and triggers callback if provided', () => {
const cb = jest.fn();
const { getByTestId } = render(<HookConsumer itemsCountCallback={cb} />);
(getByTestId('count-call') as HTMLButtonElement).click();
expect(getByTestId('counted').textContent).toBe('true');
expect(cb).toHaveBeenCalledWith(5);
});
test('addListItems initializes itemsListInstance and appends only new items', () => {
const mockInit = initItemsList as jest.Mock;
const { getByTestId, rerender } = render(<HookConsumer />);
const itemsLen = getByTestId('len') as HTMLDivElement;
const addBtn = getByTestId('add-call') as HTMLButtonElement;
const loadBtn = getByTestId('load-call') as HTMLButtonElement;
expect(itemsLen.textContent).toBe('0');
loadBtn.click();
expect(itemsLen.textContent).toBe('2');
expect(mockInit).toHaveBeenCalledTimes(0);
addBtn.click();
expect(mockInit).toHaveBeenCalledTimes(1);
expect(mockInit.mock.results[0].value[0].appendItems).toHaveBeenCalledTimes(2);
loadBtn.click();
expect(itemsLen.textContent).toBe('2');
addBtn.click();
expect(mockInit).toHaveBeenCalledTimes(2);
expect(mockInit.mock.results[1].value[0].appendItems).toHaveBeenCalledTimes(2);
rerender(<HookConsumer />);
addBtn.click();
expect(mockInit).toHaveBeenCalledTimes(3);
expect(mockInit.mock.results[2].value[0].appendItems).toHaveBeenCalledTimes(2);
});
test('addListItems does nothing when there are no .item elements in the ref', () => {
// Render, do not call onItemsLoad, then call addListItems
const mockInit = initItemsList as jest.Mock;
const { getByTestId } = render(<HookConsumer />);
(getByTestId('add-call') as HTMLButtonElement).click();
expect(mockInit).not.toHaveBeenCalled();
});
test('itemsLoadCallback is invoked when items change', () => {
const itemsLoadCallback = jest.fn();
const { getByTestId } = render(<HookConsumer itemsLoadCallback={itemsLoadCallback} />);
(getByTestId('load-call') as HTMLButtonElement).click();
expect(itemsLoadCallback).toHaveBeenCalledTimes(1);
});
test('setListHandler updates listHandler', () => {
const { getByTestId } = render(<HookConsumer />);
expect(getByTestId('has-handler').textContent).toBe('no');
(getByTestId('set-handler') as HTMLButtonElement).click();
expect(getByTestId('has-handler').textContent).toBe('yes');
});
});
});

View File

@@ -0,0 +1,346 @@
import React from 'react';
import { render, fireEvent, act } from '@testing-library/react';
jest.mock('../../../src/static/js/utils/settings/config', () => ({
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
}));
jest.mock('../../../src/static/js/utils/classes/', () => ({
BrowserCache: jest.fn().mockImplementation(() => ({
get: jest.fn(),
set: jest.fn(),
})),
}));
jest.mock('../../../src/static/js/utils/helpers/', () => ({
addClassname: jest.fn(),
removeClassname: jest.fn(),
}));
let mockListHandler: any;
let mockInlineSliderInstance: any;
let addListItemsSpy = jest.fn();
jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({
useItemList: (props: any, _ref: any) => {
mockListHandler = {
loadItems: jest.fn(),
totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1),
loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)),
};
return [
props.__items ?? [], // items
props.__countedItems ?? 0, // countedItems
mockListHandler, // listHandler
jest.fn(), // setListHandler
jest.fn(), // onItemsLoad
jest.fn(), // onItemsCount
addListItemsSpy, // addListItems
];
},
}));
jest.mock('../../../src/static/js/components/item-list/includes/itemLists/ItemsInlineSlider', () =>
jest.fn().mockImplementation(() => {
mockInlineSliderInstance = {
updateDataStateOnResize: jest.fn(),
updateDataState: jest.fn(),
scrollToCurrentSlide: jest.fn(),
nextSlide: jest.fn(),
previousSlide: jest.fn(),
hasNextSlide: jest.fn().mockReturnValue(true),
hasPreviousSlide: jest.fn().mockReturnValue(true),
loadItemsToFit: jest.fn().mockReturnValue(false),
loadMoreItems: jest.fn().mockReturnValue(false),
itemsFit: jest.fn().mockReturnValue(3),
};
return mockInlineSliderInstance;
})
);
jest.mock('../../../src/static/js/components/_shared', () => ({
CircleIconButton: ({ children, onClick }: any) => (
<button data-testid="circle-icon-button" onClick={onClick}>
{children}
</button>
),
}));
import { useItemListInlineSlider } from '../../../src/static/js/utils/hooks/useItemListInlineSlider';
function HookConsumer(props: any) {
const tuple = useItemListInlineSlider(props);
const [
_items,
_countedItems,
_listHandler,
classname,
_setListHandler,
_onItemsCount,
_onItemsLoad,
_winResizeListener,
_sidebarVisibilityChangeListener,
itemsListWrapperRef,
_itemsListRef,
renderBeforeListWrap,
renderAfterListWrap,
] = tuple as any;
return (
<div ref={itemsListWrapperRef}>
<div data-testid="class-list">{classname.list}</div>
<div data-testid="class-outer">{classname.listOuter}</div>
<div data-testid="render-before">{renderBeforeListWrap()}</div>
<div data-testid="render-after">{renderAfterListWrap()}</div>
</div>
);
}
describe('utils/hooks', () => {
describe('useItemListInlineSlider', () => {
beforeEach(() => {
addListItemsSpy = jest.fn();
mockInlineSliderInstance = null;
});
afterEach(() => {
jest.clearAllMocks();
});
test('Returns correct tuple of values from hook', () => {
const TestComponent = (props: any) => {
const tuple = useItemListInlineSlider(props);
return (
<div>
<div data-testid="tuple-length">{tuple.length}</div>
<div data-testid="has-items">{tuple[0] ? 'yes' : 'no'}</div>
<div data-testid="has-classname">{tuple[3] ? 'yes' : 'no'}</div>
<div data-testid="has-listeners">{typeof tuple[7] === 'function' ? 'yes' : 'no'}</div>
</div>
);
};
const { getByTestId } = render(<TestComponent __items={[1, 2, 3]} />);
expect(getByTestId('tuple-length').textContent).toBe('13');
expect(getByTestId('has-classname').textContent).toBe('yes');
expect(getByTestId('has-listeners').textContent).toBe('yes');
});
test('Computes classname.list and classname.listOuter with optional className prop', () => {
const { getByTestId, rerender } = render(<HookConsumer className=" extra " />);
expect(getByTestId('class-outer').textContent).toBe('items-list-outer list-inline list-slider extra ');
expect(getByTestId('class-list').textContent).toBe('items-list');
rerender(<HookConsumer />);
expect(getByTestId('class-outer').textContent).toBe('items-list-outer list-inline list-slider');
expect(getByTestId('class-list').textContent).toBe('items-list');
});
test('Invokes addListItems when items change', () => {
const { rerender } = render(<HookConsumer __items={[]} />);
expect(addListItemsSpy).toHaveBeenCalledTimes(1);
rerender(<HookConsumer __items={[1]} />);
expect(addListItemsSpy).toHaveBeenCalledTimes(2);
});
test('nextSlide loads more items when loadMoreItems returns true and not all items loaded', () => {
const { getByTestId } = render(<HookConsumer __items={[1, 2, 3]} __loadedAll={false} />);
mockInlineSliderInstance.loadMoreItems.mockReturnValue(true);
const renderAfter = getByTestId('render-after');
const nextButton = renderAfter.querySelector('button[data-testid="circle-icon-button"]');
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
fireEvent.click(nextButton!);
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(1);
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(1);
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
});
test('nextSlide does not load items when all items already loaded', () => {
const { getByTestId } = render(<HookConsumer __items={[1, 2, 3]} __loadedAll={true} />);
mockInlineSliderInstance.loadMoreItems.mockReturnValue(false);
const renderAfter = getByTestId('render-after');
const nextButton = renderAfter.querySelector('button[data-testid="circle-icon-button"]');
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
fireEvent.click(nextButton!);
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(1);
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
});
test('prevSlide calls inlineSlider.previousSlide and updates button view', () => {
const { getByTestId } = render(<HookConsumer __items={[1, 2, 3]} __loadedAll={false} />);
mockInlineSliderInstance.loadMoreItems.mockReturnValue(true);
const renderBefore = getByTestId('render-before');
const prevButton = renderBefore.querySelector('button[data-testid="circle-icon-button"]');
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
fireEvent.click(prevButton!);
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(1);
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
});
test('prevSlide always scrolls to current slide regardless of item load state', () => {
const { getByTestId } = render(<HookConsumer __items={[1, 2, 3]} __loadedAll={true} />);
mockInlineSliderInstance.loadMoreItems.mockReturnValue(false);
const renderBefore = getByTestId('render-before');
const prevButton = renderBefore.querySelector('button[data-testid="circle-icon-button"]');
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
fireEvent.click(prevButton!);
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(1);
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
});
test('Button state updates based on hasNextSlide and hasPreviousSlide', () => {
const { getByTestId, rerender } = render(<HookConsumer __items={[1, 2, 3]} />);
const renderBefore = getByTestId('render-before');
const renderAfter = getByTestId('render-after');
// Initially should show buttons (default mock returns true)
expect(renderBefore.querySelector('button')).toBeTruthy();
expect(renderAfter.querySelector('button')).toBeTruthy();
// Now set hasNextSlide and hasPreviousSlide to false
mockInlineSliderInstance.hasNextSlide.mockReturnValue(false);
mockInlineSliderInstance.hasPreviousSlide.mockReturnValue(false);
// Trigger re-render by changing items
rerender(<HookConsumer __items={[1, 2, 3, 4]} />);
// The next and previous buttons should not be rendered now
const newRenderAfter = getByTestId('render-after');
const newRenderBefore = getByTestId('render-before');
expect(newRenderAfter.querySelector('button')).toBeNull();
expect(newRenderBefore.querySelector('button')).toBeNull();
});
test('winResizeListener and sidebarVisibilityChangeListener are returned as callable functions', () => {
const TestComponentWithListeners = (props: any) => {
const tuple = useItemListInlineSlider(props);
const winResizeListener = tuple[7]; // winResizeListener
const sidebarListener = tuple[8]; // sidebarVisibilityChangeListener
const wrapperRef = tuple[9]; // itemsListWrapperRef
return (
<div ref={wrapperRef as any} data-testid="wrapper">
<button data-testid="trigger-resize" onClick={winResizeListener as any}>
Trigger Resize
</button>
<button data-testid="trigger-sidebar" onClick={sidebarListener as any}>
Trigger Sidebars
</button>
</div>
);
};
const { getByTestId } = render(<TestComponentWithListeners __items={[1, 2, 3]} />);
// Should not throw when called
const resizeButton = getByTestId('trigger-resize');
const sidebarButton = getByTestId('trigger-sidebar');
expect(() => fireEvent.click(resizeButton)).not.toThrow();
expect(() => fireEvent.click(sidebarButton)).not.toThrow();
});
test('winResizeListener updates resizeDate state triggering resize effect', () => {
const TestComponent = (props: any) => {
const tuple = useItemListInlineSlider(props) as any;
const winResizeListener = tuple[7];
const wrapperRef = tuple[9];
return (
<div ref={wrapperRef} data-testid="wrapper">
<button data-testid="trigger-resize" onClick={winResizeListener}>
Trigger Resize
</button>
</div>
);
};
const { getByTestId } = render(<TestComponent __items={[1, 2, 3]} />);
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(0);
jest.useFakeTimers();
fireEvent.click(getByTestId('trigger-resize'));
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(200);
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(3);
expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});
});
});

View File

@@ -0,0 +1,190 @@
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
jest.mock('../../../src/static/js/utils/settings/config', () => ({
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
}));
jest.mock('../../../src/static/js/utils/classes/', () => ({
BrowserCache: jest.fn().mockImplementation(() => ({
get: jest.fn(),
set: jest.fn(),
})),
}));
let mockListHandler: any;
let addListItemsSpy = jest.fn();
const mockRemoveListener = jest.fn();
jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({
useItemList: (props: any, _ref: any) => {
mockListHandler = {
loadItems: jest.fn(),
totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1),
loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)),
};
return [
props.__items ?? [], // items
props.__countedItems ?? 0, // countedItems
mockListHandler, // listHandler
jest.fn(), // setListHandler
jest.fn(), // onItemsLoad
jest.fn(), // onItemsCount
addListItemsSpy, // addListItems
];
},
}));
jest.mock('../../../src/static/js/utils/stores/', () => ({
PageStore: {
removeListener: mockRemoveListener,
},
}));
import { useItemListLazyLoad } from '../../../src/static/js/utils/hooks/useItemListLazyLoad';
function HookConsumer(props: any) {
const tuple = useItemListLazyLoad(props);
const [
_items,
_countedItems,
_listHandler,
_setListHandler,
classname,
_onItemsCount,
_onItemsLoad,
_onWindowScroll,
_onDocumentVisibilityChange,
_itemsListWrapperRef,
_itemsListRef,
renderBeforeListWrap,
renderAfterListWrap,
] = tuple as any;
return (
<div>
<div data-testid="class-list">{classname.list}</div>
<div data-testid="class-outer">{classname.listOuter}</div>
<div data-testid="render-before">{renderBeforeListWrap()}</div>
<div data-testid="render-after">{renderAfterListWrap()}</div>
</div>
);
}
function HookConsumerWithRefs(props: any) {
const tuple = useItemListLazyLoad(props);
const [
_items,
_countedItems,
_listHandler,
_setListHandler,
classname,
_onItemsCount,
_onItemsLoad,
onWindowScroll,
onDocumentVisibilityChange,
itemsListWrapperRef,
itemsListRef,
renderBeforeListWrap,
renderAfterListWrap,
] = tuple as any;
return (
<div ref={itemsListWrapperRef}>
<div data-testid="class-list">{classname.list}</div>
<div data-testid="class-outer">{classname.listOuter}</div>
<div ref={itemsListRef} data-testid="list-ref-node" />
<div data-testid="render-before">{renderBeforeListWrap()}</div>
<div data-testid="render-after">{renderAfterListWrap()}</div>
<button data-testid="trigger-visibility" onClick={onDocumentVisibilityChange} type="button">
visibility
</button>
<button data-testid="trigger-scroll" onClick={onWindowScroll} type="button">
scroll
</button>
</div>
);
}
describe('utils/hooks', () => {
describe('useItemListLazyLoad', () => {
beforeEach(() => {
addListItemsSpy = jest.fn();
mockRemoveListener.mockClear();
});
afterEach(() => {
jest.clearAllMocks();
});
test('Computes classname.list and classname.listOuter with optional className prop', () => {
const { getByTestId, rerender } = render(<HookConsumer className=" extra " />);
expect(getByTestId('class-outer').textContent).toBe('items-list-outer extra');
expect(getByTestId('class-list').textContent).toBe('items-list');
rerender(<HookConsumer />);
expect(getByTestId('class-outer').textContent).toBe('items-list-outer');
expect(getByTestId('class-list').textContent).toBe('items-list');
});
test('Invokes addListItems when items change', () => {
const { rerender } = render(<HookConsumer __items={[]} />);
expect(addListItemsSpy).toHaveBeenCalledTimes(1);
rerender(<HookConsumer __items={[1]} />);
expect(addListItemsSpy).toHaveBeenCalledTimes(2);
});
test('Renders nothing in renderBeforeListWrap and renderAfterListWrap', () => {
const { getByTestId } = render(
<HookConsumer __items={[1]} __countedItems={1} __totalPages={3} __loadedAll={false} />
);
expect(getByTestId('render-before').textContent).toBe('');
expect(getByTestId('render-after').textContent).toBe('');
});
test('Does not call listHandler.loadItems when refs are not attached', () => {
render(<HookConsumer __items={[1]} />);
expect(mockListHandler.loadItems).not.toHaveBeenCalled();
});
test('Calls listHandler.loadItems when refs are set and scroll threshold is reached', async () => {
render(<HookConsumerWithRefs __items={[1]} __loadedAll={false} />);
await waitFor(() => {
expect(mockListHandler.loadItems).toHaveBeenCalled();
});
});
test('Calls PageStore.removeListener when refs are set and loadedAllItems is true', () => {
render(<HookConsumerWithRefs __items={[1]} __loadedAll={true} />);
expect(mockRemoveListener).toHaveBeenCalledWith('window_scroll', expect.any(Function));
});
test('onDocumentVisibilityChange schedules onWindowScroll when document is visible', () => {
jest.useFakeTimers();
const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout');
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
const { getByTestId } = render(<HookConsumerWithRefs __items={[1]} />);
fireEvent.click(getByTestId('trigger-visibility'));
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10);
setTimeoutSpy.mockRestore();
jest.useRealTimers();
});
test('onDocumentVisibilityChange does nothing when document is hidden', () => {
jest.useFakeTimers();
const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout');
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
const { getByTestId } = render(<HookConsumerWithRefs __items={[1]} />);
fireEvent.click(getByTestId('trigger-visibility'));
expect(setTimeoutSpy).toHaveBeenCalledTimes(0);
setTimeoutSpy.mockRestore();
jest.useRealTimers();
});
});
});

View File

@@ -0,0 +1,156 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
jest.mock('../../../src/static/js/utils/settings/config', () => ({
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
}));
jest.mock('../../../src/static/js/utils/classes/', () => ({
BrowserCache: jest.fn().mockImplementation(() => ({
get: jest.fn(),
set: jest.fn(),
})),
}));
jest.mock('../../../src/static/js/utils/helpers/', () => ({
translateString: (s: string) => s,
}));
let mockListHandler: any;
let mockOnItemsLoad = jest.fn();
let mockOnItemsCount = jest.fn();
let addListItemsSpy = jest.fn();
// Mock useItemList to control items, counts, and listHandler
jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({
useItemList: (props: any, _ref: any) => {
mockListHandler = {
loadItems: jest.fn(),
totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1),
loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)),
};
return [
props.__items ?? [], // items
props.__countedItems ?? 0, // countedItems
mockListHandler, // listHandler
jest.fn(), // setListHandler
mockOnItemsLoad, // onItemsLoad
mockOnItemsCount, // onItemsCount
addListItemsSpy, // addListItems
];
},
}));
import { useItemListSync } from '../../../src/static/js/utils/hooks/useItemListSync';
function HookConsumer(props: any) {
const tuple = useItemListSync(props);
const [
_countedItems,
_items,
_listHandler,
_setListHandler,
classname,
_itemsListWrapperRef,
_itemsListRef,
_onItemsCount,
_onItemsLoad,
renderBeforeListWrap,
renderAfterListWrap,
] = tuple as any;
return (
<div>
{/* <div data-testid="counted">{String(countedItems)}</div> */}
{/* <div data-testid="items">{Array.isArray(items) ? items.length : 0}</div> */}
<div data-testid="class-list">{classname.list}</div>
<div data-testid="class-outer">{classname.listOuter}</div>
{/* <div data-testid="has-handler">{listHandler ? 'yes' : 'no'}</div> */}
{/* <div data-testid="wrapper-ref">{itemsListWrapperRef.current ? 'set' : 'unset'}</div> */}
{/* <div data-testid="list-ref">{itemsListRef.current ? 'set' : 'unset'}</div> */}
<div data-testid="render-before">{renderBeforeListWrap()}</div>
<div data-testid="render-after">{renderAfterListWrap()}</div>
{/* <button data-testid="call-on-load" onClick={() => onItemsLoad([])} /> */}
{/* <button data-testid="call-on-count" onClick={() => onItemsCount(0)} /> */}
</div>
);
}
describe('utils/hooks', () => {
describe('useItemListSync', () => {
beforeEach(() => {
mockOnItemsLoad = jest.fn();
mockOnItemsCount = jest.fn();
addListItemsSpy = jest.fn();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Classname Management', () => {
test('Computes classname.listOuter with optional className prop', () => {
const { getByTestId, rerender } = render(<HookConsumer className=" extra " />);
expect(getByTestId('class-outer').textContent).toBe('items-list-outer extra');
expect(getByTestId('class-list').textContent).toBe('items-list');
rerender(<HookConsumer />);
expect(getByTestId('class-outer').textContent).toBe('items-list-outer');
expect(getByTestId('class-list').textContent).toBe('items-list');
});
});
describe('Items Management', () => {
test('Invokes addListItems and afterItemsLoad when items change', () => {
const { rerender } = render(<HookConsumer __items={[]} />);
expect(addListItemsSpy).toHaveBeenCalledTimes(1);
rerender(<HookConsumer __items={[1]} />);
// useEffect runs again due to items change
expect(addListItemsSpy).toHaveBeenCalledTimes(2);
});
});
describe('Load More Button Rendering', () => {
test('Renders SHOW MORE button when more pages exist and not loaded all', () => {
const { getByTestId } = render(
<HookConsumer __items={[1]} __countedItems={1} __totalPages={3} __loadedAll={false} />
);
const btn = getByTestId('render-after').querySelector('button.load-more') as HTMLButtonElement;
expect(btn).toBeTruthy();
expect(btn.textContent).toBe('SHOW MORE');
fireEvent.click(btn);
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(1);
});
test('Hides SHOW MORE when totalPages <= 1', () => {
const { getByTestId } = render(
// With totalPages=1 the hook should not render the button regardless of loadedAll
<HookConsumer __items={[1, 2]} __countedItems={2} __totalPages={1} __loadedAll={true} />
);
expect(getByTestId('render-after').textContent).toBe('');
});
test('Hides SHOW MORE when loadedAllItems is true', () => {
const { getByTestId } = render(
<HookConsumer __items={[1, 2, 3]} __countedItems={3} __totalPages={5} __loadedAll={true} />
);
expect(getByTestId('render-after').textContent).toBe('');
});
test('Shows SHOW MORE when loadedAllItems is false even with totalPages > 1', () => {
const { getByTestId } = render(
<HookConsumer __items={[1, 2]} __countedItems={2} __totalPages={2} __loadedAll={false} />
);
const btn = getByTestId('render-after').querySelector('button.load-more');
expect(btn).toBeTruthy();
});
test('Returns null from renderBeforeListWrap', () => {
const { getByTestId } = render(
<HookConsumer __items={[1]} __countedItems={1} __totalPages={3} __loadedAll={false} />
);
expect(getByTestId('render-before').textContent).toBe('');
});
});
});
});

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { act, render } from '@testing-library/react';
import { useLayout } from '../../../src/static/js/utils/hooks/useLayout';
jest.mock('../../../src/static/js/utils/classes/', () => ({
BrowserCache: jest.fn().mockImplementation(() => ({
get: (key: string) => {
let result: any = undefined;
switch (key) {
case 'visible-sidebar':
result = true;
break;
}
return result;
},
set: jest.fn(),
})),
}));
jest.mock('../../../src/static/js/utils/dispatcher.js', () => ({
register: jest.fn(),
}));
jest.mock('../../../src/static/js/utils/settings/config', () => ({
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
}));
import { LayoutProvider } from '../../../src/static/js/utils/contexts';
describe('utils/hooks', () => {
describe('useLayout', () => {
test('Returns default value', () => {
let received: ReturnType<typeof useLayout> | undefined;
const Comp: React.FC = () => {
received = useLayout();
return null;
};
render(
<LayoutProvider>
<Comp />
</LayoutProvider>
);
expect(received).toStrictEqual({
enabledSidebar: false,
visibleSidebar: true,
visibleMobileSearch: false,
setVisibleSidebar: expect.any(Function),
toggleMobileSearch: expect.any(Function),
toggleSidebar: expect.any(Function),
});
});
test('Returns undefined value when used without a Provider', () => {
let received: any = 'init';
const Comp: React.FC = () => {
received = useLayout();
return null;
};
render(<Comp />);
expect(received).toBe(undefined);
});
test('Toggle sidebar', () => {
jest.useFakeTimers();
let received: ReturnType<typeof useLayout> | undefined;
const Comp: React.FC = () => {
received = useLayout();
return null;
};
render(
<LayoutProvider>
<Comp />
</LayoutProvider>
);
act(() => received?.toggleSidebar());
jest.advanceTimersByTime(241);
expect(received?.visibleSidebar).toBe(false);
act(() => received?.toggleSidebar());
jest.advanceTimersByTime(241);
expect(received?.visibleSidebar).toBe(true);
jest.useRealTimers();
});
test('Toggle mobile search', () => {
let received: ReturnType<typeof useLayout> | undefined;
const Comp: React.FC = () => {
received = useLayout();
return null;
};
render(
<LayoutProvider>
<Comp />
</LayoutProvider>
);
act(() => received?.toggleMobileSearch());
expect(received?.visibleMobileSearch).toBe(true);
act(() => received?.toggleMobileSearch());
expect(received?.visibleMobileSearch).toBe(false);
});
});
});

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { useManagementTableHeader } from '../../../src/static/js/utils/hooks/useManagementTableHeader';
function HookConsumer(props: {
order: 'asc' | 'desc';
selected: boolean;
sort: string;
type: 'comments' | 'media' | 'users';
onCheckAllRows?: (newSort: string, newOrder: 'asc' | 'desc') => void;
onClickColumnSort?: (newSelected: boolean, newType: 'comments' | 'media' | 'users') => void;
}) {
const tuple = useManagementTableHeader(props) as [
string,
'asc' | 'desc',
boolean,
React.MouseEventHandler,
() => void,
];
const [sort, order, isSelected, sortByColumn, checkAll] = tuple;
return (
<div>
<div data-testid="sort">{sort}</div>
<div data-testid="order">{order}</div>
<div data-testid="selected">{String(isSelected)}</div>
<button id="title" data-testid="col-title" onClick={sortByColumn} />
<button id="views" data-testid="col-views" onClick={sortByColumn} />
<button data-testid="check-all" onClick={checkAll} />
</div>
);
}
describe('utils/hooks', () => {
describe('useManagementTableHeader', () => {
test('Returns a 5-tuple in expected order and reflects initial props', () => {
let tuple: any;
const Comp: React.FC = () => {
tuple = useManagementTableHeader({ sort: 'title', order: 'asc', selected: false });
return null;
};
render(<Comp />);
expect(Array.isArray(tuple)).toBe(true);
expect(tuple).toHaveLength(5);
const [sort, order, isSelected] = tuple;
expect(sort).toBe('title');
expect(order).toBe('asc');
expect(isSelected).toBe(false);
});
test('sortByColumn toggles order when clicking same column and updates sort when clicking different column', () => {
const onClickColumnSort = jest.fn();
const { getByTestId, rerender } = render(
<HookConsumer
sort="title"
order="desc"
type="media"
selected={false}
onClickColumnSort={onClickColumnSort}
/>
);
// Initial state
expect(getByTestId('sort').textContent).toBe('title');
expect(getByTestId('order').textContent).toBe('desc');
// Click same column -> toggle order to asc
fireEvent.click(getByTestId('col-title'));
expect(onClickColumnSort).toHaveBeenLastCalledWith('title', 'asc');
// Rerender to ensure state settled in testing DOM
rerender(
<HookConsumer
sort="title"
order="asc"
type="media"
selected={false}
onClickColumnSort={onClickColumnSort}
/>
);
// Click same column -> toggle order to desc
fireEvent.click(getByTestId('col-title'));
expect(onClickColumnSort).toHaveBeenLastCalledWith('title', 'desc');
// Click different column -> set sort to that column and default order desc
fireEvent.click(getByTestId('col-views'));
expect(onClickColumnSort).toHaveBeenLastCalledWith('views', 'desc');
});
test('checkAll inverts current selection and invokes callback with newSelected and type', () => {
const onCheckAllRows = jest.fn();
const { getByTestId } = render(
<HookConsumer sort="title" order="asc" selected={false} type="media" onCheckAllRows={onCheckAllRows} />
);
expect(getByTestId('selected').textContent).toBe('false');
fireEvent.click(getByTestId('check-all'));
// newSelected computed as !isSelected -> true
expect(onCheckAllRows).toHaveBeenCalledWith(true, 'media');
});
test('Effects update internal state when props change', () => {
const { getByTestId, rerender } = render(
<HookConsumer sort="title" order="asc" type="media" selected={false} />
);
expect(getByTestId('sort').textContent).toBe('title');
expect(getByTestId('order').textContent).toBe('asc');
expect(getByTestId('selected').textContent).toBe('false');
rerender(<HookConsumer sort="views" order="desc" type="media" selected={true} />);
expect(getByTestId('sort').textContent).toBe('views');
expect(getByTestId('order').textContent).toBe('desc');
expect(getByTestId('selected').textContent).toBe('true');
});
test('Does not throw when optional callbacks are not provided', () => {
const { getByTestId } = render(<HookConsumer sort="x" order="desc" type="media" selected={false} />);
expect(() => fireEvent.click(getByTestId('col-title'))).not.toThrow();
expect(() => fireEvent.click(getByTestId('check-all'))).not.toThrow();
});
});
});

View File

@@ -0,0 +1,119 @@
import React from 'react';
import { render } from '@testing-library/react';
import { useMediaFilter } from '../../../src/static/js/utils/hooks/useMediaFilter';
jest.mock('../../../src/static/js/components/_shared/popup/PopupContent', () => ({
PopupContent: (props: any) => React.createElement('div', props, props.children),
}));
jest.mock('../../../src/static/js/components/_shared/popup/PopupTrigger', () => ({
PopupTrigger: (props: any) => React.createElement('div', props, props.children),
}));
function HookConsumer({ initial }: { initial: string }) {
const tuple = useMediaFilter(initial) as [
React.RefObject<any>,
string,
React.Dispatch<React.SetStateAction<string>>,
React.RefObject<any>,
React.ReactNode,
React.ReactNode,
];
const [containerRef, value, setValue, popupContentRef, PopupContent, PopupTrigger] = tuple;
return (
<div>
<div data-testid="container-ref">{containerRef && typeof containerRef === 'object' ? 'ok' : 'bad'}</div>
<div data-testid="value">{value}</div>
<button data-testid="set" onClick={() => setValue('updated')} />
<div data-testid="popup-ref">{popupContentRef && typeof popupContentRef === 'object' ? 'ok' : 'bad'}</div>
{typeof PopupContent === 'function' ? React.createElement(PopupContent, null, 'c') : null}
{typeof PopupTrigger === 'function' ? React.createElement(PopupTrigger, null, 't') : null}
</div>
);
}
describe('utils/hooks', () => {
describe('useMediaFilter', () => {
test('Returns a 6-tuple in expected order', () => {
let tuple: any;
const Comp: React.FC = () => {
tuple = useMediaFilter('init');
return null;
};
render(<Comp />);
expect(Array.isArray(tuple)).toBe(true);
expect(tuple).toHaveLength(6);
const [containerRef, value, setValue, popupContentRef, PopupContent, PopupTrigger] = tuple;
expect(containerRef).toBeDefined();
expect(containerRef.current).toBe(null);
expect(value).toBe('init');
expect(typeof setValue).toBe('function');
expect(popupContentRef).toBeDefined();
expect(typeof PopupContent).toBe('function');
expect(typeof PopupTrigger).toBe('function');
});
test('Initial value is respected and can be updated via setter', () => {
const { getByTestId } = render(<HookConsumer initial="first" />);
expect(getByTestId('value').textContent).toBe('first');
getByTestId('set').click();
expect(getByTestId('value').textContent).toBe('updated');
});
test('containerRef and popupContentRef are mutable ref objects', () => {
let data: any;
const Comp: React.FC = () => {
data = useMediaFilter('x');
return null;
};
render(<Comp />);
const [containerRef, _value, _setValue, popupContentRef] = data;
expect(containerRef.current).toBe(null);
expect(popupContentRef.current).toBe(null);
});
test('PopupContent and PopupTrigger are stable functions', () => {
let first: any;
let second: any;
const First: React.FC = () => {
first = useMediaFilter('a');
return null;
};
const Second: React.FC = () => {
second = useMediaFilter('b');
return null;
};
const Parent: React.FC = () => (
<>
<First />
<Second />
</>
);
render(<Parent />);
const [, , , , PopupContent1, PopupTrigger1] = first;
const [, , , , PopupContent2, PopupTrigger2] = second;
expect(typeof PopupContent1).toBe('function');
expect(typeof PopupTrigger1).toBe('function');
expect(PopupContent1).toBe(PopupContent2);
expect(PopupTrigger1).toBe(PopupTrigger2);
});
});
});

View File

@@ -0,0 +1,289 @@
import React from 'react';
import { render } from '@testing-library/react';
import { useMediaItem, itemClassname } from '../../../src/static/js/utils/hooks/useMediaItem';
// Mock dependencies used by useMediaItem
// @todo: Revisit this
jest.mock('../../../src/static/js/utils/stores/', () => ({
PageStore: { get: (_: string) => ({ url: 'https://example.com' }) },
}));
jest.mock('../../../src/static/js/components/list-item/includes/items', () => ({
MediaItemAuthor: ({ name }: any) => <div data-testid="author" data-name={name} />,
MediaItemAuthorLink: ({ name, link }: any) => (
<a data-testid="author-link" data-name={name} href={link || undefined} />
),
MediaItemMetaViews: ({ views }: any) => <span data-testid="views" data-views={views} />,
MediaItemMetaDate: ({ time, dateTime, text }: any) => (
<time data-testid="date" data-time={String(time)} data-datetime={String(dateTime)}>
{text}
</time>
),
MediaItemEditLink: ({ link }: any) => <a data-testid="edit" href={link} />,
MediaItemViewLink: ({ link }: any) => <a data-testid="view" href={link} />,
}));
// @todo: Revisit this
// useItem returns titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper
jest.mock('../../../src/static/js/utils/hooks/useItem', () => ({
useItem: (props: any) => ({
titleComponent: () => <h3 data-testid="title">{props.title || 'title'}</h3>,
descriptionComponent: () => <p data-testid="desc">{props.description || 'desc'}</p>,
thumbnailUrl: props.thumb || 'thumb.jpg',
UnderThumbWrapper: ({ children }: any) => <div data-testid="under-thumb">{children}</div>,
}),
}));
function HookConsumer(props: any) {
const [TitleComp, DescComp, thumbUrl, UnderThumbComp, EditComp, MetaComp, ViewComp] = useMediaItem(props);
// The hook returns functions/components/values. To satisfy TS, render using React.createElement
return (
<div>
{typeof TitleComp === 'function' ? React.createElement(TitleComp) : null}
{typeof DescComp === 'function' ? React.createElement(DescComp) : null}
<div data-testid="thumb">{typeof thumbUrl === 'string' ? thumbUrl : ''}</div>
{typeof UnderThumbComp === 'function'
? React.createElement(
UnderThumbComp,
null,
typeof EditComp === 'function' ? React.createElement(EditComp) : null,
typeof MetaComp === 'function' ? React.createElement(MetaComp) : null,
typeof ViewComp === 'function' ? React.createElement(ViewComp) : null
)
: null}
</div>
);
}
describe('utils/hooks', () => {
describe('useMediaItem', () => {
describe('itemClassname utility function', () => {
test('Returns default classname when no modifications', () => {
expect(itemClassname('base', '', false)).toBe('base');
});
test('Appends inherited classname when provided', () => {
expect(itemClassname('base', 'extra', false)).toBe('base extra');
});
test('Appends pl-active-item when isActiveInPlaylistPlayback is true', () => {
expect(itemClassname('base', '', true)).toBe('base pl-active-item');
});
test('Appends both inherited classname and active state', () => {
expect(itemClassname('base', 'extra', true)).toBe('base extra pl-active-item');
});
});
describe('Basic Rendering', () => {
test('Renders basic components from useItem and edit/view links', () => {
// @todo: Revisit this
const props = {
title: 'My Title',
description: 'My Desc',
thumbnail: 'thumb.jpg',
link: '/watch/1',
singleLinkContent: true,
// hasMediaViewer:...
// hasMediaViewerDescr:...
// meta_description:...
// onMount:...
// type:...
// ------------------------------
editLink: '/edit/1',
showSelection: true,
// publishLink: ...
// hideAuthor:...
author_name: 'Author',
author_link: '/u/author',
// hideViews:...
views: 10,
// hideDate:...
publish_date: '2020-01-01T00:00:00Z',
// hideAllMeta:...
};
const { getByTestId, queryByTestId } = render(<HookConsumer {...props} />);
expect(getByTestId('title').textContent).toBe(props.title);
expect(getByTestId('desc').textContent).toBe(props.description);
expect(getByTestId('thumb').textContent).toBe('thumb.jpg');
expect(getByTestId('edit').getAttribute('href')).toBe(props.editLink);
expect(getByTestId('views').getAttribute('data-views')).toBe(props.views.toString());
expect(getByTestId('date')).toBeTruthy();
expect(getByTestId('view').getAttribute('href')).toBe(props.link);
expect(queryByTestId('author')).toBeTruthy();
});
});
describe('View Link Selection', () => {
test('Uses publishLink when provided and showSelection=true', () => {
const props = {
editLink: '/edit/2',
link: '/watch/2',
publishLink: '/publish/2',
showSelection: true,
singleLinkContent: true,
author_name: 'A',
author_link: '',
views: 0,
publish_date: 0,
};
const { getByTestId } = render(<HookConsumer {...props} />);
expect(getByTestId('view').getAttribute('href')).toBe(props.publishLink);
});
});
describe('Visibility Controls', () => {
test('Hides author, views, and date based on props', () => {
const props = {
editLink: '/e',
link: '/l',
showSelection: true,
hideAuthor: true,
hideViews: true,
hideDate: true,
publish_date: '2020-01-01T00:00:00Z',
views: 5,
author_name: 'Hidden',
author_link: '/u/x',
};
const { queryByTestId } = render(<HookConsumer {...props} />);
expect(queryByTestId('author')).toBeNull();
expect(queryByTestId('views')).toBeNull();
expect(queryByTestId('date')).toBeNull();
});
test('Author link resolves using formatInnerLink and PageStore base url when singleLinkContent=false', () => {
const props = {
editLink: '/e',
link: '/l',
showSelection: true,
singleLinkContent: false,
hideAuthor: false,
author_name: 'John',
author_link: '/u/john',
publish_date: '2020-01-01T00:00:00Z',
};
const { container } = render(<HookConsumer {...props} />);
const a = container.querySelector('[data-testid="author-link"]') as HTMLAnchorElement;
expect(a).toBeTruthy();
expect(a.getAttribute('href')).toBe(`https://example.com${props.author_link}`);
expect(a.getAttribute('data-name')).toBe(props.author_name);
});
});
describe('Meta Visibility', () => {
test('Meta wrapper hidden when hideAllMeta=true', () => {
const props = {
editLink: '/e',
link: '/l',
showSelection: true,
hideAllMeta: true,
publish_date: '2020-01-01T00:00:00Z',
};
const { queryByTestId } = render(<HookConsumer {...props} />);
expect(queryByTestId('author')).toBeNull();
expect(queryByTestId('views')).toBeNull();
expect(queryByTestId('date')).toBeNull();
});
test('Meta wrapper hidden individually by hideAuthor, hideViews, hideDate', () => {
const props = {
editLink: '/e',
link: '/l',
showSelection: true,
hideAuthor: true,
hideViews: false,
hideDate: false,
publish_date: '2020-01-01T00:00:00Z',
views: 5,
author_name: 'Test',
author_link: '/u/test',
};
const { queryByTestId } = render(<HookConsumer {...props} />);
expect(queryByTestId('author')).toBeNull();
expect(queryByTestId('views')).toBeTruthy();
expect(queryByTestId('date')).toBeTruthy();
});
});
describe('Edge Cases & Date Handling', () => {
test('Handles views when hideViews is false', () => {
const props = {
editLink: '/e',
link: '/l',
showSelection: true,
hideViews: false,
views: 100,
publish_date: '2020-01-01T00:00:00Z',
author_name: 'A',
author_link: '/u/a',
};
const { getByTestId } = render(<HookConsumer {...props} />);
expect(getByTestId('views')).toBeTruthy();
expect(getByTestId('views').getAttribute('data-views')).toBe('100');
});
test('Renders without showSelection', () => {
const props = {
editLink: '/e',
link: '/l',
showSelection: false,
publish_date: '2020-01-01T00:00:00Z',
author_name: 'A',
author_link: '/u/a',
};
const { queryByTestId } = render(<HookConsumer {...props} />);
expect(queryByTestId('view')).toBeNull();
});
test('Handles numeric publish_date correctly', () => {
const props = {
editLink: '/e',
link: '/l',
showSelection: true,
publish_date: 1577836800000, // 2020-01-01 as timestamp
author_name: 'A',
author_link: '/u/a',
};
const { getByTestId } = render(<HookConsumer {...props} />);
expect(getByTestId('date')).toBeTruthy();
});
test('Handles empty author_link by setting it to null', () => {
const props = {
editLink: '/e',
link: '/l',
showSelection: true,
singleLinkContent: false,
author_name: 'Anonymous',
author_link: '', // Empty link
publish_date: '2020-01-01T00:00:00Z',
};
const { container } = render(<HookConsumer {...props} />);
const authorLink = container.querySelector('[data-testid="author-link"]') as HTMLAnchorElement;
expect(authorLink).toBeTruthy();
expect(authorLink.getAttribute('href')).toBeNull();
});
});
});
});

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