feat: LTI support and Moodle plugin

This commit is contained in:
Markos Gogoulos
2026-05-11 12:47:09 +03:00
committed by GitHub
parent b7427869b6
commit 55ab7ff34f
307 changed files with 19966 additions and 3748 deletions
@@ -54,7 +54,7 @@
.category-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
padding: 20px 24px;
border-bottom: 1px solid #e0e0e0;
@@ -74,6 +74,19 @@
}
}
.category-modal-subtitle {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
font-size: 13px;
color: #777;
.dark_theme & {
color: #aaa;
}
}
.category-modal-close {
background: none;
border: none;
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import './BulkActionCategoryModal.scss';
import { translateString } from '../utils/helpers/';
import { inEmbeddedApp } from '../utils/helpers/embeddedApp';
interface Category {
title: string;
@@ -24,6 +25,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
onError,
csrfToken,
}) => {
const isLmsMode = inEmbeddedApp();
const [existingCategories, setExistingCategories] = useState<Category[]>([]);
const [allCategories, setAllCategories] = useState<Category[]>([]);
const [categoriesToAdd, setCategoriesToAdd] = useState<Category[]>([]);
@@ -66,20 +68,27 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
const existingData = await existingResponse.json();
const existing = existingData.results || [];
// Fetch all categories
const allResponse = await fetch('/api/v1/categories');
// Fetch all categories (or LMS courses only in embed mode)
const categoriesUrl = isLmsMode
? '/api/v1/categories/contributor?lms_courses_only=true'
: '/api/v1/categories';
const allResponse = await fetch(categoriesUrl);
if (!allResponse.ok) {
throw new Error(translateString('Failed to fetch all categories'));
throw new Error(isLmsMode ? translateString('Failed to fetch courses') : translateString('Failed to fetch all categories'));
}
const allData = await allResponse.json();
const all = allData.results || allData;
setExistingCategories(existing);
// In LMS mode, filter existing to only show LMS course categories
const allUids = new Set(all.map((c: Category) => c.uid));
const filteredExisting = isLmsMode ? existing.filter((c: Category) => allUids.has(c.uid)) : existing;
setExistingCategories(filteredExisting);
setAllCategories(all);
} catch (error) {
console.error('Error fetching categories:', error);
onError(translateString('Failed to load categories'));
onError(isLmsMode ? translateString('Failed to load courses') : translateString('Failed to load categories'));
} finally {
setIsLoading(false);
}
@@ -126,7 +135,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
});
if (!addResponse.ok) {
throw new Error(translateString('Failed to add categories'));
throw new Error(isLmsMode ? translateString('Failed to add courses') : translateString('Failed to add categories'));
}
}
@@ -147,15 +156,15 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
});
if (!removeResponse.ok) {
throw new Error(translateString('Failed to remove categories'));
throw new Error(isLmsMode ? translateString('Failed to remove courses') : translateString('Failed to remove categories'));
}
}
onSuccess(translateString('Successfully updated categories'));
onSuccess(isLmsMode ? translateString('Successfully updated courses') : translateString('Successfully updated categories'));
onCancel();
} catch (error) {
console.error('Error processing categories:', error);
onError(translateString('Failed to update categories. Please try again.'));
onError(isLmsMode ? translateString('Failed to update courses. Please try again.') : translateString('Failed to update categories. Please try again.'));
} finally {
setIsProcessing(false);
}
@@ -184,7 +193,14 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
<div className="category-modal-overlay">
<div className="category-modal">
<div className="category-modal-header">
<h2>{translateString('Add / Remove from Categories')}</h2>
<div>
<h2>{isLmsMode ? translateString('Share with Course') : translateString('Add / Remove from Categories')}</h2>
{isLmsMode && (
<div className="category-modal-subtitle">
<span>{translateString('Students will get viewer permissions, while lecturers will get co-owner permissions (same as owner, but cannot delete the media)')}</span>
</div>
)}
</div>
<button className="category-modal-close" onClick={onCancel}>
×
</button>
@@ -192,14 +208,14 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
<div className="category-modal-content">
<div className="category-panel">
<h3>{translateString('Categories')}</h3>
<h3>{isLmsMode ? translateString('Courses') : translateString('Categories')}</h3>
{isLoading ? (
<div className="loading-message">{translateString('Loading categories...')}</div>
<div className="loading-message">{isLmsMode ? translateString('Loading courses...') : translateString('Loading categories...')}</div>
) : (
<div className="category-list scrollable">
{leftPanelCategories.length === 0 ? (
<div className="empty-message">{translateString('All categories already added')}</div>
<div className="empty-message">{isLmsMode ? translateString('All courses already added') : translateString('All categories already added')}</div>
) : (
leftPanelCategories.map((category) => (
<div
@@ -227,11 +243,11 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
</h3>
{isLoading ? (
<div className="loading-message">{translateString('Loading categories...')}</div>
<div className="loading-message">{isLmsMode ? translateString('Loading courses...') : translateString('Loading categories...')}</div>
) : (
<div className="category-list scrollable">
{rightPanelCategories.length === 0 ? (
<div className="empty-message">{translateString('No categories')}</div>
<div className="empty-message">{isLmsMode ? translateString('No courses') : translateString('No categories')}</div>
) : (
rightPanelCategories.map((category) => {
const isExisting = existingCategories.some((c) => c.uid === category.uid);
@@ -251,7 +267,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
removeCategoryFromAddList(category);
}
}}
title={isMarkedForRemoval ? translateString('Undo removal') : isExisting ? translateString('Remove category') : translateString('Remove from list')}
title={isMarkedForRemoval ? translateString('Undo removal') : isExisting ? (isLmsMode ? translateString('Remove course') : translateString('Remove category')) : translateString('Remove from list')}
>
{isMarkedForRemoval ? '↺' : '×'}
</button>
@@ -47,7 +47,7 @@ export const BulkActionChangeOwnerModal: React.FC<BulkActionChangeOwnerModalProp
}
try {
const response = await fetch(`/api/v1/users?name=${encodeURIComponent(name)}`);
const response = await fetch(`/api/v1/users?name=${encodeURIComponent(name)}&exclude_self=True`);
if (!response.ok) {
throw new Error(translateString('Failed to search users'));
}
@@ -0,0 +1,33 @@
.course-cleanup-options {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.course-cleanup-checkbox {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: #555;
cursor: pointer;
line-height: 1.4;
.dark_theme & {
color: #ccc;
}
input[type='checkbox'] {
flex-shrink: 0;
margin-top: 2px;
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--default-theme-color, #009933);
}
span {
flex: 1;
}
}
@@ -0,0 +1,237 @@
import React, { useState, useEffect } from 'react';
import './BulkActionCategoryModal.scss';
import './BulkActionCourseCleanupModal.scss';
import { translateString } from '../utils/helpers/';
interface Course {
title: string;
uid: string;
}
interface BulkActionCourseCleanupModalProps {
isOpen: boolean;
selectedMediaIds: string[];
onCancel: () => void;
onSuccess: (message: string) => void;
onError: (message: string) => void;
csrfToken: string;
}
export const BulkActionCourseCleanupModal: React.FC<BulkActionCourseCleanupModalProps> = ({
isOpen,
selectedMediaIds,
onCancel,
onSuccess,
onError,
csrfToken,
}) => {
const hasMediaSelected = selectedMediaIds.length > 0;
const [availableCourses, setAvailableCourses] = useState<Course[]>([]);
const [coursesToCleanup, setCoursesToCleanup] = useState<Course[]>([]);
const [removePermissions, setRemovePermissions] = useState(false);
const [removeComments, setRemoveComments] = useState(false);
const [applyToAll, setApplyToAll] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => {
if (isOpen) {
fetchCourses();
} else {
setAvailableCourses([]);
setCoursesToCleanup([]);
setRemovePermissions(false);
setRemoveComments(false);
setApplyToAll(false);
}
}, [isOpen, selectedMediaIds.join(',')]);
const fetchCourses = async () => {
setIsLoading(true);
try {
const contributorResponse = await fetch('/api/v1/categories/contributor?lms_courses_only=true');
if (!contributorResponse.ok) throw new Error('Failed to fetch courses');
const contributorData = await contributorResponse.json();
const allContributorCourses: Course[] = contributorData.results || contributorData;
if (hasMediaSelected) {
const membershipResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ action: 'category_membership', media_ids: selectedMediaIds }),
});
if (!membershipResponse.ok) throw new Error('Failed to fetch media categories');
const membershipData = await membershipResponse.json();
const mediaCategoryUids = new Set((membershipData.results || []).map((c: Course) => c.uid));
setAvailableCourses(allContributorCourses.filter((c) => mediaCategoryUids.has(c.uid)));
} else {
setAvailableCourses(allContributorCourses);
}
} catch (error) {
onError(translateString('Failed to load courses'));
} finally {
setIsLoading(false);
}
};
const addCourseToCleanup = (course: Course) => {
if (!coursesToCleanup.some((c) => c.uid === course.uid)) {
setCoursesToCleanup((prev) => [...prev, course]);
setAvailableCourses((prev) => prev.filter((c) => c.uid !== course.uid));
}
};
const removeCourseFromCleanup = (course: Course) => {
setCoursesToCleanup((prev) => prev.filter((c) => c.uid !== course.uid));
setAvailableCourses((prev) => [...prev, course]);
};
const handleProceed = async () => {
if (coursesToCleanup.length === 0) return;
setIsProcessing(true);
try {
const response = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({
action: 'course_cleanup',
media_ids: selectedMediaIds,
category_uids: coursesToCleanup.map((c) => c.uid),
remove_permissions: removePermissions,
remove_comments: removeComments,
apply_to_all: applyToAll,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || 'Failed');
}
onSuccess(translateString('Course cleanup completed successfully'));
onCancel();
} catch (error: any) {
onError(error.message || translateString('Course cleanup failed. Please try again.'));
} finally {
setIsProcessing(false);
}
};
if (!isOpen) return null;
return (
<div className="category-modal-overlay">
<div className="category-modal">
<div className="category-modal-header">
<div>
<h2>{translateString('Course Cleanup')}</h2>
<div className="category-modal-subtitle">
<span>
{translateString(
'Cleanup irrelevant course content such as old user permissions and course tags. Add course to the right column and select what to cleanup under the Column. The cleanup can apply to the media you have selected only or to all media in the course, if that option is selected.'
)}
</span>
</div>
</div>
<button className="category-modal-close" onClick={onCancel}>
×
</button>
</div>
<div className="category-modal-content">
<div className="category-panel">
<h3>{translateString('Courses available')}</h3>
{isLoading ? (
<div className="loading-message">{translateString('Loading courses...')}</div>
) : (
<div className="category-list scrollable">
{availableCourses.length === 0 ? (
<div className="empty-message">{translateString('No courses available')}</div>
) : (
availableCourses.map((course) => (
<div
key={course.uid}
className="category-item clickable"
onClick={() => addCourseToCleanup(course)}
>
<span>{course.title}</span>
<button className="add-btn">+</button>
</div>
))
)}
</div>
)}
</div>
<div className="category-panel">
<h3>{translateString('Courses to cleanup')}</h3>
{isLoading ? (
<div className="loading-message">{translateString('Loading courses...')}</div>
) : (
<>
<div className="category-list scrollable">
{coursesToCleanup.length === 0 ? (
<div className="empty-message">{translateString('No courses selected')}</div>
) : (
coursesToCleanup.map((course) => (
<div key={course.uid} className="category-item">
<span>{course.title}</span>
<button
className="remove-btn"
onClick={() => removeCourseFromCleanup(course)}
title={translateString('Remove from list')}
>
×
</button>
</div>
))
)}
</div>
<div className="course-cleanup-options">
<label className="course-cleanup-checkbox">
<input
type="checkbox"
checked={removePermissions}
onChange={(e) => setRemovePermissions(e.target.checked)}
/>
<span>{translateString('Remove present course permissions for all course members')}</span>
</label>
<label className="course-cleanup-checkbox">
<input
type="checkbox"
checked={removeComments}
onChange={(e) => setRemoveComments(e.target.checked)}
/>
<span>{translateString('Remove Comments')}</span>
</label>
{hasMediaSelected && (
<label className="course-cleanup-checkbox">
<input
type="checkbox"
checked={applyToAll}
onChange={(e) => setApplyToAll(e.target.checked)}
/>
<span>{translateString('Apply cleanup to all media shared in the course')}</span>
</label>
)}
</div>
</>
)}
</div>
</div>
<div className="category-modal-footer">
<button className="category-btn category-btn-cancel" onClick={onCancel} disabled={isProcessing}>
{translateString('Cancel')}
</button>
<button
className="category-btn category-btn-proceed"
onClick={handleProceed}
disabled={isProcessing || coursesToCleanup.length === 0}
>
{isProcessing ? translateString('Processing...') : translateString('Proceed')}
</button>
</div>
</div>
</div>
);
};
@@ -54,7 +54,7 @@
.permission-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
padding: 20px 24px;
border-bottom: 1px solid #e0e0e0;
@@ -102,6 +102,19 @@
}
}
.permission-modal-subtitle {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
font-size: 13px;
color: #777;
.dark_theme & {
color: #aaa;
}
}
.permission-modal-content {
display: flex;
gap: 24px;
@@ -226,7 +226,42 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
<div className="permission-modal-overlay">
<div className="permission-modal">
<div className="permission-modal-header">
<h2>{translateString('Manage')} {permissionLabel}</h2>
<div>
<h2>{translateString('Share with')} {permissionLabel}</h2>
{permissionType === 'viewer' && (
<div className="permission-modal-subtitle">
<span>{translateString('Give users viewer permissions to your media by adding them to the below list.')}</span>
<span
className="info-tooltip"
title={translateString("Users can view your media via: My Media > Shared with Me > particular media > ...")}
>
i
</span>
</div>
)}
{permissionType === 'editor' && (
<div className="permission-modal-subtitle">
<span>{translateString('Give users editor permissions to your media by adding them to the below list.')}</span>
<span
className="info-tooltip"
title={translateString("Users can edit your media via: My Media > Shared with Me > particular media > Edit...")}
>
i
</span>
</div>
)}
{permissionType === 'owner' && (
<div className="permission-modal-subtitle">
<span>{translateString('Give users owner permissions to your media, except for deleting the media, by adding them to the below list.')}</span>
<span
className="info-tooltip"
title={translateString("Users can manage your media via: My Media > Shared with Me > particular media > ...")}
>
i
</span>
</div>
)}
</div>
<button className="permission-modal-close" onClick={onCancel}>
×
</button>
@@ -284,6 +319,21 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
?
</span>
)}
{permissionType === 'viewer' && (
<span className="info-tooltip" title={translateString('Remove users from the list to remove viewer permissions')}>
i
</span>
)}
{permissionType === 'editor' && (
<span className="info-tooltip" title={translateString('Remove users from the list to remove editor permissions')}>
i
</span>
)}
{permissionType === 'owner' && (
<span className="info-tooltip" title={translateString('Remove users from the list to remove owner permissions')}>
i
</span>
)}
</h3>
<div className="search-box">
<input
@@ -159,6 +159,56 @@
}
}
.shared-selector {
display: flex;
flex-direction: column;
gap: 6px;
&-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: #333;
cursor: pointer;
user-select: none;
input[type='checkbox'] {
width: 16px;
height: 16px;
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
.dark_theme & {
color: #fff;
}
}
&-note {
margin: 0;
font-size: 12px;
color: #777;
&--warn {
color: #b45309;
}
.dark_theme & {
color: #aaa;
&--warn {
color: #f59e0b;
}
}
}
}
.publish-state-modal-footer {
display: flex;
justify-content: flex-end;
@@ -25,13 +25,21 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
onError,
csrfToken,
}) => {
const [selectedState, setSelectedState] = useState('public');
const isLmsEmbedMode =
sessionStorage.getItem('lms_embed_mode') === 'true' ||
new URLSearchParams(window.location.search).get('mode') === 'lms_embed_mode';
const availableStates = isLmsEmbedMode ? PUBLISH_STATES.filter((s) => s.value !== 'public') : PUBLISH_STATES;
const [selectedState, setSelectedState] = useState('');
const [removeSharing, setRemoveSharing] = useState(false);
const [acknowledged, setAcknowledged] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => {
if (!isOpen) {
// Reset state when modal closes
setSelectedState('public');
setSelectedState('');
setRemoveSharing(false);
setAcknowledged(false);
}
}, [isOpen]);
@@ -40,21 +48,30 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
onError(translateString('Please select a publish state'));
return;
}
if (removeSharing && !acknowledged) {
onError(translateString('Please acknowledge the sharing removal'));
return;
}
setIsProcessing(true);
try {
const body: Record<string, unknown> = {
action: 'set_state',
media_ids: selectedMediaIds,
state: selectedState,
};
if (removeSharing) {
body.remove_sharing = true;
}
const response = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'set_state',
media_ids: selectedMediaIds,
state: selectedState,
}),
body: JSON.stringify(body),
});
if (!response.ok) {
@@ -74,10 +91,6 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
if (!isOpen) return null;
// Note: We don't check hasStateChanged because the modal doesn't know the actual
// current state of the selected media. Users should be able to set any state.
// If the state is already the same, the backend will handle it gracefully.
return (
<div className="publish-state-modal-overlay">
<div className="publish-state-modal">
@@ -97,13 +110,45 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
onChange={(e) => setSelectedState(e.target.value)}
disabled={isProcessing}
>
{PUBLISH_STATES.map((state) => (
<option value="" disabled>
{translateString('— select —')}
</option>
{availableStates.map((state) => (
<option key={state.value} value={state.value}>
{state.label}
</option>
))}
</select>
</div>
<div className="shared-selector">
<label className="shared-selector-label">
<input
type="checkbox"
checked={removeSharing}
onChange={(e) => {
setRemoveSharing(e.target.checked);
if (!e.target.checked) setAcknowledged(false);
}}
disabled={isProcessing}
/>
{translateString('Remove Sharing')}
</label>
<p className="shared-selector-note shared-selector-note--warn">
{translateString('Sharing will be removed from all selected media.')}
</p>
{removeSharing && (
<label className="shared-selector-label shared-selector-acknowledge">
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
disabled={isProcessing}
/>
{translateString('I understand that this will remove all existing sharing for this media.')}
</label>
)}
</div>
</div>
<div className="publish-state-modal-footer">
@@ -113,7 +158,7 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
<button
className="publish-state-btn publish-state-btn-submit"
onClick={handleSubmit}
disabled={isProcessing}
disabled={isProcessing || !selectedState || (removeSharing && !acknowledged)}
>
{isProcessing ? translateString('Processing...') : translateString('Submit')}
</button>
@@ -1,89 +1,203 @@
@import '../../css/config/index.scss';
@keyframes bulk-menu-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.bulk-actions-dropdown {
position: relative;
display: inline-block;
margin-bottom: 16px;
.bulk-actions-select {
width: auto;
max-width: 220px;
.bulk-actions-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 28px 0 10px;
padding: 0 12px;
font-size: 13px;
font-weight: 600;
color: #333;
background-color: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
border: 1px solid #d0d0d0;
border-radius: 5px;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 14px;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
white-space: nowrap;
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&:hover {
background-color: #e8e8e8;
border-color: #ccc;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
background-color: #e4e4e4;
border-color: #bbb;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.14);
}
&:focus {
outline: none;
border-color: var(--default-theme-color, #009933);
box-shadow: 0 0 0 3px rgba(0, 153, 51, 0.25);
box-shadow: 0 0 0 3px rgba(0, 153, 51, 0.2);
}
&:active {
background-color: #dcdcdc;
box-shadow: none;
}
&.no-selection {
color: #666;
color: #777;
}
option {
padding: 10px;
font-weight: normal;
font-style: normal;
color: #333;
background-color: white;
&.is-open {
background-color: #e4e4e4;
border-color: var(--default-theme-color, #009933);
box-shadow: 0 0 0 2px rgba(0, 153, 51, 0.15);
}
&:disabled {
color: #999;
}
.bulk-actions-chevron {
flex-shrink: 0;
transition: transform 0.2s ease;
opacity: 0.6;
}
&:not(:disabled) {
color: #000;
}
&.is-open .bulk-actions-chevron {
transform: rotate(180deg);
opacity: 1;
}
}
.bulk-actions-menu {
position: absolute;
top: calc(100% + 5px);
left: 0;
z-index: 1000;
min-width: 230px;
max-height: 340px;
overflow-y: auto;
background: #fff;
border: 1px solid #d8d8d8;
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.13), 0 2px 6px rgba(0, 0, 0, 0.08);
animation: bulk-menu-in 0.15s ease;
}
.bulk-actions-group {
padding: 6px 0;
& + & {
border-top: 1px solid #efefef;
}
}
.bulk-actions-group-label {
padding: 4px 14px 6px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #aaa;
user-select: none;
}
.bulk-actions-item {
display: block;
width: 100%;
padding: 8px 14px;
text-align: left;
font-size: 13px;
font-weight: 400;
color: #222;
background: none;
border: none;
border-left: 3px solid transparent;
cursor: pointer;
transition: background-color 0.1s ease, border-left-color 0.1s ease, color 0.1s ease;
&:hover:not(:disabled) {
background-color: #f0f7f3;
border-left-color: var(--default-theme-color, #009933);
color: #000;
}
&:active:not(:disabled) {
background-color: #dff0e7;
}
&:disabled,
&.is-disabled {
color: #ccc;
cursor: default;
border-left-color: transparent;
}
}
.dark_theme & {
.bulk-actions-select {
color: #fff;
.bulk-actions-trigger {
color: #e0e0e0;
background-color: #3a3a3a;
border-color: #555;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
border-color: #505050;
&:hover {
background-color: #454545;
background-color: #444;
border-color: #666;
}
&:focus {
border-color: var(--default-theme-color, #009933);
box-shadow: 0 0 0 3px rgba(0, 153, 51, 0.2);
}
&:active {
background-color: #333;
}
&.no-selection {
color: #aaa;
color: #888;
}
option {
background-color: #2a2a2a;
color: #fff;
&.is-open {
background-color: #444;
border-color: var(--default-theme-color, #009933);
box-shadow: 0 0 0 2px rgba(0, 153, 51, 0.15);
}
}
&:disabled {
color: #777;
}
.bulk-actions-menu {
background: #2c2c2c;
border-color: #484848;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
}
.bulk-actions-group + .bulk-actions-group {
border-top-color: #383838;
}
.bulk-actions-group-label {
color: #666;
}
.bulk-actions-item {
color: #ddd;
&:hover:not(:disabled) {
background-color: #1a3325;
border-left-color: var(--default-theme-color, #009933);
color: #fff;
}
&:active:not(:disabled) {
background-color: #163020;
}
&:disabled,
&.is-disabled {
color: #484848;
}
}
}
@@ -1,69 +1,153 @@
import React from 'react';
import React, { useState, useRef, useEffect } from 'react';
import './BulkActionsDropdown.scss';
import { translateString } from '../utils/helpers/';
import { inEmbeddedApp } from '../utils/helpers/embeddedApp';
interface BulkActionsDropdownProps {
selectedCount: number;
onActionSelect: (action: string) => void;
hasContributorCourses?: boolean;
}
const BULK_ACTIONS = [
{ value: 'add-remove-coviewers', label: translateString('Add / Remove Co-Viewers'), enabled: true },
{ value: 'add-remove-coeditors', label: translateString('Add / Remove Co-Editors'), enabled: true },
{ value: 'add-remove-coowners', label: translateString('Add / Remove Co-Owners'), enabled: true },
{ value: 'add-remove-playlist', label: translateString('Add to / Remove from Playlist'), enabled: true },
{ value: 'add-remove-category', label: translateString('Add to / Remove from Category'), enabled: true },
{ value: 'add-remove-tags', label: translateString('Add / Remove Tags'), enabled: true },
{ value: 'enable-comments', label: translateString('Enable Comments'), enabled: true },
{ value: 'disable-comments', label: translateString('Disable Comments'), enabled: true },
{ value: 'enable-download', label: translateString('Enable Download'), enabled: true },
{ value: 'disable-download', label: translateString('Disable Download'), enabled: true },
{ value: 'publish-state', label: translateString('Publish State'), enabled: true },
{ value: 'change-owner', label: translateString('Change Owner'), enabled: true },
{ value: 'copy-media', label: translateString('Copy Media'), enabled: true },
{ value: 'delete-media', label: translateString('Delete Media'), enabled: true },
];
interface BulkAction {
value: string;
label: string;
enabled: boolean;
allowsNoSelection?: boolean;
}
interface BulkActionGroup {
label: string;
actions: BulkAction[];
}
export const BulkActionsDropdown: React.FC<BulkActionsDropdownProps> = ({ selectedCount, onActionSelect, hasContributorCourses = false }) => {
const isLmsMode = inEmbeddedApp();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const BULK_ACTION_GROUPS: BulkActionGroup[] = [
{
label: translateString('Sharing'),
actions: [
{ value: 'add-remove-coviewers', label: translateString('Share with Co-Viewers'), enabled: true },
{ value: 'add-remove-coeditors', label: translateString('Share with Co-Editors'), enabled: true },
{ value: 'add-remove-coowners', label: translateString('Share with Co-Owners'), enabled: true },
{ value: 'add-remove-category', label: isLmsMode ? translateString('Share with Course Members') : translateString('Add / Remove from Categories'), enabled: true },
],
},
{
label: translateString('Organization'),
actions: [
{ value: 'add-remove-playlist', label: translateString('Add to / Remove from Playlist'), enabled: true },
{ value: 'add-remove-tags', label: translateString('Add / Remove Tags'), enabled: true },
],
},
{
label: translateString('Settings'),
actions: [
{ value: 'enable-comments', label: translateString('Enable Comments'), enabled: true },
{ value: 'disable-comments', label: translateString('Disable Comments'), enabled: true },
{ value: 'delete-comments', label: translateString('Delete Comments'), enabled: true },
{ value: 'enable-download', label: translateString('Enable Download'), enabled: true },
{ value: 'disable-download', label: translateString('Disable Download'), enabled: true },
],
},
{
label: translateString('Management'),
actions: [
{ value: 'publish-state', label: translateString('Publish State'), enabled: true },
{ value: 'change-owner', label: translateString('Change Owner'), enabled: true },
{ value: 'copy-media', label: translateString('Copy Media'), enabled: true },
{ value: 'delete-media', label: translateString('Delete Media'), enabled: true },
...(isLmsMode && hasContributorCourses
? [{ value: 'course-cleanup', label: translateString('Course Cleanup'), enabled: true, allowsNoSelection: true }]
: []),
],
},
];
export const BulkActionsDropdown: React.FC<BulkActionsDropdownProps> = ({ selectedCount, onActionSelect }) => {
const noSelection = selectedCount === 0;
const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const value = event.target.value;
if (!value) return;
if (noSelection) {
event.target.value = '';
return;
}
onActionSelect(value);
// Reset dropdown after selection
event.target.value = '';
};
const displayText = noSelection
? translateString('Bulk Actions')
: `${translateString('Bulk Actions')} (${selectedCount} ${translateString('selected')})`;
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsOpen(false);
};
document.addEventListener('mousedown', handler);
document.addEventListener('keydown', keyHandler);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('keydown', keyHandler);
};
}, [isOpen]);
const handleSelect = (action: BulkAction) => {
const isDisabled = (!action.allowsNoSelection && noSelection) || !action.enabled;
if (isDisabled) return;
onActionSelect(action.value);
setIsOpen(false);
};
return (
<div className="bulk-actions-dropdown">
<select
className={'bulk-actions-select' + (noSelection ? ' no-selection' : '')}
onChange={handleChange}
value=""
aria-label={translateString('Bulk Actions')}
<div className="bulk-actions-dropdown" ref={dropdownRef}>
<button
type="button"
className={'bulk-actions-trigger' + (noSelection ? ' no-selection' : '') + (isOpen ? ' is-open' : '')}
onClick={() => setIsOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<option value="" disabled>
{displayText}
</option>
{BULK_ACTIONS.map((action) => (
<option key={action.value} value={action.value} disabled={noSelection || !action.enabled}>
{action.label}
</option>
))}
</select>
{displayText}
<svg
className="bulk-actions-chevron"
xmlns="http://www.w3.org/2000/svg"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{isOpen && (
<div className="bulk-actions-menu" role="listbox">
{BULK_ACTION_GROUPS.map((group) => (
<div key={group.label} className="bulk-actions-group">
<div className="bulk-actions-group-label">{group.label}</div>
{group.actions.map((action) => {
const isDisabled = (!action.allowsNoSelection && noSelection) || !action.enabled;
return (
<button
key={action.value}
type="button"
className={'bulk-actions-item' + (isDisabled ? ' is-disabled' : '')}
onClick={() => handleSelect(action)}
disabled={isDisabled}
role="option"
>
{action.label}
</button>
);
})}
</div>
))}
</div>
)}
</div>
);
};
@@ -7,6 +7,7 @@ import { BulkActionChangeOwnerModal } from './BulkActionChangeOwnerModal';
import { BulkActionPublishStateModal } from './BulkActionPublishStateModal';
import { BulkActionCategoryModal } from './BulkActionCategoryModal';
import { BulkActionTagModal } from './BulkActionTagModal';
import { BulkActionCourseCleanupModal } from './BulkActionCourseCleanupModal';
/**
* Renders all bulk action modals
@@ -58,6 +59,12 @@ export function BulkActionsModals({
onTagModalSuccess,
onTagModalError,
// Course cleanup modal props
showCourseCleanupModal,
onCourseCleanupModalCancel,
onCourseCleanupModalSuccess,
onCourseCleanupModalError,
// Common props
csrfToken,
@@ -131,6 +138,15 @@ export function BulkActionsModals({
csrfToken={csrfToken}
/>
<BulkActionCourseCleanupModal
isOpen={showCourseCleanupModal}
selectedMediaIds={selectedMediaIds}
onCancel={onCourseCleanupModalCancel}
onSuccess={onCourseCleanupModalSuccess}
onError={onCourseCleanupModalError}
csrfToken={csrfToken}
/>
{showNotification && (
<div
style={{
@@ -193,6 +209,11 @@ BulkActionsModals.propTypes = {
onTagModalSuccess: PropTypes.func.isRequired,
onTagModalError: PropTypes.func.isRequired,
showCourseCleanupModal: PropTypes.bool.isRequired,
onCourseCleanupModalCancel: PropTypes.func.isRequired,
onCourseCleanupModalSuccess: PropTypes.func.isRequired,
onCourseCleanupModalError: PropTypes.func.isRequired,
csrfToken: PropTypes.string.isRequired,
showNotification: PropTypes.bool.isRequired,
@@ -1,5 +1,5 @@
import React from 'react';
import { translateString } from '../utils/helpers/';
import { translateString, inSelectMediaEmbedMode } from '../utils/helpers/';
interface MediaListHeaderProps {
title?: string;
@@ -11,10 +11,12 @@ interface MediaListHeaderProps {
export const MediaListHeader: React.FC<MediaListHeaderProps> = (props) => {
const viewAllText = props.viewAllText || translateString('VIEW ALL');
const isSelectMediaMode = inSelectMediaEmbedMode();
return (
<div className={(props.className ? props.className + ' ' : '') + 'media-list-header'} style={props.style}>
<h2>{props.title}</h2>
{props.viewAllLink ? (
{!isSelectMediaMode && props.viewAllLink ? (
<h3>
{' '}
<a href={props.viewAllLink} title={viewAllText}>
@@ -16,8 +16,17 @@
margin-bottom: 16px;
.add-media-button {
position: relative;
margin-left: auto;
.popup {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
z-index: 99;
}
a {
display: flex;
align-items: center;
@@ -2,8 +2,9 @@ import React from 'react';
import { MediaListRow } from './MediaListRow';
import { BulkActionsDropdown } from './BulkActionsDropdown';
import { SelectAllCheckbox } from './SelectAllCheckbox';
import { CircleIconButton, MaterialIcon } from './_shared';
import { CircleIconButton, MaterialIcon, PopupMain, NavigationMenuList } from './_shared';
import { LinksConsumer } from '../utils/contexts';
import { usePopup } from '../utils/hooks';
import { translateString } from '../utils/helpers/';
import './MediaListWrapper.scss';
@@ -21,6 +22,7 @@ interface MediaListWrapperProps {
onSelectAll?: () => void;
onDeselectAll?: () => void;
showAddMediaButton?: boolean;
hasContributorCourses?: boolean;
}
export const MediaListWrapper: React.FC<MediaListWrapperProps> = ({
@@ -37,36 +39,61 @@ export const MediaListWrapper: React.FC<MediaListWrapperProps> = ({
onSelectAll = () => {},
onDeselectAll = () => {},
showAddMediaButton = false,
}) => (
<div className={(className ? className + ' ' : '') + 'media-list-wrapper'} style={style}>
<MediaListRow title={title} viewAllLink={viewAllLink} viewAllText={viewAllText}>
{showBulkActions && (
<LinksConsumer>
{(links) => (
<div className="bulk-actions-container">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<BulkActionsDropdown selectedCount={selectedCount} onActionSelect={onBulkAction} />
<SelectAllCheckbox
totalCount={totalCount}
selectedCount={selectedCount}
onSelectAll={onSelectAll}
onDeselectAll={onDeselectAll}
/>
</div>
{showAddMediaButton && (
<div className="add-media-button">
<a href={links.user.addMedia} title={translateString('Add media')}>
<CircleIconButton>
<MaterialIcon type="video_call" />
</CircleIconButton>
</a>
hasContributorCourses = false,
}) => {
const [popupContentRef, PopupContent, PopupTrigger] = usePopup() as [any, any, any];
return (
<div className={(className ? className + ' ' : '') + 'media-list-wrapper'} style={style}>
<MediaListRow title={title} viewAllLink={viewAllLink} viewAllText={viewAllText}>
{showBulkActions && (
<LinksConsumer>
{(links) => {
const uploadMenuItems = [
{
link: links.user.addMedia,
icon: 'upload',
text: translateString('Upload'),
},
{
link: '/record_screen',
icon: 'videocam',
text: translateString('Record'),
},
];
return (
<div className="bulk-actions-container">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<BulkActionsDropdown selectedCount={selectedCount} onActionSelect={onBulkAction} hasContributorCourses={hasContributorCourses} />
<SelectAllCheckbox
totalCount={totalCount}
selectedCount={selectedCount}
onSelectAll={onSelectAll}
onDeselectAll={onDeselectAll}
/>
</div>
{showAddMediaButton && (
<div className="add-media-button">
<PopupTrigger contentRef={popupContentRef}>
<CircleIconButton title={translateString('Add media')}>
<MaterialIcon type="video_call" />
</CircleIconButton>
</PopupTrigger>
<PopupContent contentRef={popupContentRef}>
<PopupMain>
<NavigationMenuList items={uploadMenuItems} />
</PopupMain>
</PopupContent>
</div>
)}
</div>
)}
</div>
)}
</LinksConsumer>
)}
{children || null}
</MediaListRow>
</div>
);
);
}}
</LinksConsumer>
)}
{children || null}
</MediaListRow>
</div>
);
};
@@ -36,6 +36,7 @@ const VideoJSEmbed = ({
showRelated,
showUserAvatar,
linkTitle,
parentMediaBase,
hasTheaterMode,
hasNextLink,
nextLink,
@@ -89,6 +90,7 @@ const VideoJSEmbed = ({
previewSprite: previewSprite || null,
subtitlesInfo: subtitlesInfo || [],
inEmbed: inEmbed || false,
parentMediaBase: parentMediaBase || null,
showTitle: showTitle || false,
showRelated: showRelated !== undefined ? showRelated : (urlShowRelated === '1' || urlShowRelated === 'true' || urlShowRelated === null),
showUserAvatar: showUserAvatar !== undefined ? showUserAvatar : (urlShowUserAvatar === '1' || urlShowUserAvatar === 'true' || urlShowUserAvatar === null),
@@ -392,7 +392,7 @@ function displayCommentsRelatedAlert() {
}
}
const CommentsListHeader = ({ commentsLength }) => {
const CommentsListHeader = ({ commentsLength, ordering, onToggleOrdering }) => {
return (
<>
{!MemberContext._currentValue.can.readComment || MediaPageStore.get('media-data').enable_comments ? null : (
@@ -409,12 +409,30 @@ const CommentsListHeader = ({ commentsLength }) => {
: MediaPageStore.get('media-data').enable_comments
? translateString('No') + ' ' + commentsText.single + ' ' + translateString('yet')
: ''}
{commentsLength > 0 && (
<button className="comments-order-toggle" onClick={onToggleOrdering}>
<span className="material-icons">swap_vert</span>
<span className="comments-order-label">
{ordering === 'newest' ? translateString('Newest first') : translateString('Oldest first')}
</span>
</button>
)}
</h2>
) : null}
</>
);
};
function getSortedComments(comments, ordering) {
const sorted = [...comments];
sorted.sort((a, b) => {
const da = new Date(a.add_date);
const db = new Date(b.add_date);
return ordering === 'newest' ? db - da : da - db;
});
return sorted;
}
export default function CommentsList(props) {
const [mediaId, setMediaId] = useState(MediaPageStore.get('media-id'));
@@ -424,6 +442,12 @@ export default function CommentsList(props) {
const [displayComments, setDisplayComments] = useState(false);
const [ordering, setOrdering] = useState('newest');
function toggleOrdering() {
setOrdering((o) => (o === 'newest' ? 'oldest' : 'newest'));
}
function onCommentsLoad() {
const retrievedComments = [...MediaPageStore.get('media-comments')];
@@ -512,12 +536,12 @@ export default function CommentsList(props) {
return (
<div className="comments-list">
<div className="comments-list-inner">
<CommentsListHeader commentsLength={comments.length} />
<CommentsListHeader commentsLength={comments.length} ordering={ordering} onToggleOrdering={toggleOrdering} />
{MediaPageStore.get('media-data').enable_comments ? <CommentForm media_id={mediaId} /> : null}
{displayComments
? comments.map((c) => {
? getSortedComments(comments, ordering).map((c) => {
return (
<Comment
key={c.uid}
@@ -277,6 +277,39 @@
margin: 0 2rem 1.5rem 0;
}
.comments-order-toggle {
display: inline-flex;
align-items: center;
vertical-align: middle;
gap: 2px;
background: none;
border: 0;
padding: 2px 8px;
margin-left: 8px;
cursor: pointer;
opacity: 0.6;
border-radius: 4px;
transition: opacity 0.15s, background-color 0.15s;
&:hover,
&:focus {
opacity: 1;
background-color: rgba(128, 128, 128, 0.12);
outline: none;
}
.material-icons {
font-size: 18px;
line-height: 1;
}
.comments-order-label {
font-size: 13px;
font-weight: 400;
line-height: 1;
}
}
.disabled-comments-msg {
display: block;
text-align: center;
@@ -1,18 +1,50 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useMediaItem } from '../../utils/hooks/';
import { PositiveInteger, PositiveIntegerOrZero } from '../../utils/helpers/';
import { PositiveInteger, PositiveIntegerOrZero, inSelectMediaEmbedMode } from '../../utils/helpers/';
import { MediaItemThumbnailLink, itemClassname } from './includes/items/';
import { Item } from './Item';
export function MediaItem(props) {
const type = props.type;
const isSelectMediaMode = inSelectMediaEmbedMode();
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents, viewMediaComponent] =
const [titleComponentOrig, descriptionComponent, thumbnailUrl, UnderThumbWrapperOrig, editMediaComponent, metaComponents, viewMediaComponent] =
useMediaItem({ ...props, type });
// In embed mode, override components to remove links
const ItemTitle = ({ title }) => (
<h3>
<span>{title}</span>
</h3>
);
const ItemMain = ({ children }) => <div className="item-main">{children}</div>;
const titleComponent = isSelectMediaMode
? () => <ItemTitle title={props.title} />
: titleComponentOrig;
const UnderThumbWrapper = isSelectMediaMode ? ItemMain : UnderThumbWrapperOrig;
function thumbnailComponent() {
if (isSelectMediaMode) {
// In embed mode, render thumbnail without link
const thumbStyle = thumbnailUrl ? { backgroundImage: "url('" + thumbnailUrl + "')" } : null;
return (
<div
key="item-thumb"
className={'item-thumb' + (!thumbnailUrl ? ' no-thumb' : '')}
style={thumbStyle}
>
{thumbnailUrl ? (
<div key="item-type-icon" className="item-type-icon">
<div></div>
</div>
) : null}
</div>
);
}
return <MediaItemThumbnailLink src={thumbnailUrl} title={props.title} link={props.link} />;
}
@@ -25,11 +57,13 @@ export function MediaItem(props) {
const finalClassname = containerClassname +
(props.showSelection ? ' with-selection' : '') +
(props.isSelected ? ' selected' : '') +
(props.hasAnySelection ? ' has-any-selection' : '');
(props.hasAnySelection || isSelectMediaMode ? ' has-any-selection' : '');
const handleItemClick = (e) => {
// If there's any selection active, clicking the item should toggle selection
if (props.hasAnySelection && props.onCheckboxChange) {
const isSelectMediaMode = inSelectMediaEmbedMode();
// In select media mode or if there's any selection active, clicking the item should toggle selection
if ((isSelectMediaMode || props.hasAnySelection) && props.onCheckboxChange) {
// Check if clicking on the checkbox itself, edit icon, or view icon
if (e.target.closest('.item-selection-checkbox') ||
e.target.closest('.item-edit-icon') ||
@@ -59,16 +93,24 @@ export function MediaItem(props) {
</div>
)}
{editMediaComponent()}
{viewMediaComponent()}
{!isSelectMediaMode && editMediaComponent()}
{!isSelectMediaMode && viewMediaComponent()}
{thumbnailComponent()}
<UnderThumbWrapper title={props.title} link={props.link}>
{titleComponent()}
{metaComponents()}
{descriptionComponent()}
</UnderThumbWrapper>
{isSelectMediaMode ? (
<UnderThumbWrapper>
{titleComponent()}
{metaComponents()}
{descriptionComponent()}
</UnderThumbWrapper>
) : (
<UnderThumbWrapper title={props.title} link={props.link}>
{titleComponent()}
{metaComponents()}
{descriptionComponent()}
</UnderThumbWrapper>
)}
</div>
</div>
);
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useMediaItem } from '../../utils/hooks/';
import { PositiveIntegerOrZero } from '../../utils/helpers/';
import { PositiveIntegerOrZero, inSelectMediaEmbedMode } from '../../utils/helpers/';
import { MediaDurationInfo } from '../../utils/classes/';
import { MediaPlaylistOptions } from '../media-playlist-options/MediaPlaylistOptions';
import { MediaItemDuration, MediaItemPlaylistIndex, itemClassname } from './includes/items/';
@@ -9,10 +9,26 @@ import { MediaItem } from './MediaItem';
export function MediaItemAudio(props) {
const type = props.type;
const isSelectMediaMode = inSelectMediaEmbedMode();
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents, viewMediaComponent] =
const [titleComponentOrig, descriptionComponent, thumbnailUrl, UnderThumbWrapperOrig, editMediaComponent, metaComponents, viewMediaComponent] =
useMediaItem({ ...props, type });
// In embed mode, override components to remove links
const ItemTitle = ({ title }) => (
<h3>
<span>{title}</span>
</h3>
);
const ItemMain = ({ children }) => <div className="item-main">{children}</div>;
const titleComponent = isSelectMediaMode
? () => <ItemTitle title={props.title} />
: titleComponentOrig;
const UnderThumbWrapper = isSelectMediaMode ? ItemMain : UnderThumbWrapperOrig;
const _MediaDurationInfo = new MediaDurationInfo();
_MediaDurationInfo.update(props.duration);
@@ -22,6 +38,21 @@ export function MediaItemAudio(props) {
const durationISO8601 = _MediaDurationInfo.ISO8601();
function thumbnailComponent() {
if (isSelectMediaMode) {
// In embed mode, render thumbnail without link
return (
<div
key="item-thumb"
className={'item-thumb' + (!thumbnailUrl ? ' no-thumb' : '')}
style={!thumbnailUrl ? null : { backgroundImage: "url('" + thumbnailUrl + "')" }}
>
{props.inPlaylistView ? null : (
<MediaItemDuration ariaLabel={duration} time={durationISO8601} text={durationStr} />
)}
</div>
);
}
const attr = {
key: 'item-thumb',
href: props.link,
@@ -68,11 +99,11 @@ export function MediaItemAudio(props) {
const finalClassname = containerClassname +
(props.showSelection ? ' with-selection' : '') +
(props.isSelected ? ' selected' : '') +
(props.hasAnySelection ? ' has-any-selection' : '');
(props.hasAnySelection || isSelectMediaMode ? ' has-any-selection' : '');
const handleItemClick = (e) => {
// If there's any selection active, clicking the item should toggle selection
if (props.hasAnySelection && props.onCheckboxChange) {
// In embed mode or if there's any selection active, clicking the item should toggle selection
if ((isSelectMediaMode || props.hasAnySelection) && props.onCheckboxChange) {
// Check if clicking on the checkbox itself, edit icon, or view icon
if (e.target.closest('.item-selection-checkbox') ||
e.target.closest('.item-edit-icon') ||
@@ -104,16 +135,24 @@ export function MediaItemAudio(props) {
</div>
)}
{editMediaComponent()}
{viewMediaComponent()}
{!isSelectMediaMode && editMediaComponent()}
{!isSelectMediaMode && viewMediaComponent()}
{thumbnailComponent()}
<UnderThumbWrapper title={props.title} link={props.link}>
{titleComponent()}
{metaComponents()}
{descriptionComponent()}
</UnderThumbWrapper>
{isSelectMediaMode ? (
<UnderThumbWrapper>
{titleComponent()}
{metaComponents()}
{descriptionComponent()}
</UnderThumbWrapper>
) : (
<UnderThumbWrapper title={props.title} link={props.link}>
{titleComponent()}
{metaComponents()}
{descriptionComponent()}
</UnderThumbWrapper>
)}
{playlistOptionsComponent()}
</div>
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useMediaItem } from '../../utils/hooks/';
import { PositiveIntegerOrZero } from '../../utils/helpers/';
import { PositiveIntegerOrZero, inSelectMediaEmbedMode } from '../../utils/helpers/';
import { MediaDurationInfo } from '../../utils/classes/';
import { MediaPlaylistOptions } from '../media-playlist-options/MediaPlaylistOptions.jsx';
import { MediaItemVideoPlayer, MediaItemDuration, MediaItemVideoPreviewer, MediaItemPlaylistIndex, itemClassname } from './includes/items/';
@@ -9,10 +9,26 @@ import { MediaItem } from './MediaItem';
export function MediaItemVideo(props) {
const type = props.type;
const isSelectMediaMode = inSelectMediaEmbedMode();
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents, viewMediaComponent] =
const [titleComponentOrig, descriptionComponent, thumbnailUrl, UnderThumbWrapperOrig, editMediaComponent, metaComponents, viewMediaComponent] =
useMediaItem({ ...props, type });
// In embed mode, override components to remove links
const ItemTitle = ({ title }) => (
<h3>
<span>{title}</span>
</h3>
);
const ItemMain = ({ children }) => <div className="item-main">{children}</div>;
const titleComponent = isSelectMediaMode
? () => <ItemTitle title={props.title} />
: titleComponentOrig;
const UnderThumbWrapper = isSelectMediaMode ? ItemMain : UnderThumbWrapperOrig;
const _MediaDurationInfo = new MediaDurationInfo();
_MediaDurationInfo.update(props.duration);
@@ -26,6 +42,24 @@ export function MediaItemVideo(props) {
}
function thumbnailComponent() {
if (isSelectMediaMode) {
// In select media mode, render thumbnail without link
return (
<div
key="item-thumb"
className={'item-thumb' + (!thumbnailUrl ? ' no-thumb' : '')}
style={!thumbnailUrl ? null : { backgroundImage: "url('" + thumbnailUrl + "')" }}
>
{props.inPlaylistView ? null : (
<MediaItemDuration ariaLabel={duration} time={durationISO8601} text={durationStr} />
)}
{props.inPlaylistView || props.inPlaylistPage ? null : (
<MediaItemVideoPreviewer url={props.preview_thumbnail} />
)}
</div>
);
}
const attr = {
key: 'item-thumb',
href: props.link,
@@ -75,11 +109,11 @@ export function MediaItemVideo(props) {
const finalClassname = containerClassname +
(props.showSelection ? ' with-selection' : '') +
(props.isSelected ? ' selected' : '') +
(props.hasAnySelection ? ' has-any-selection' : '');
(props.hasAnySelection || isSelectMediaMode ? ' has-any-selection' : '');
const handleItemClick = (e) => {
// If there's any selection active, clicking the item should toggle selection
if (props.hasAnySelection && props.onCheckboxChange) {
// In select media mode or if there's any selection active, clicking the item should toggle selection
if ((isSelectMediaMode || props.hasAnySelection) && props.onCheckboxChange) {
// Check if clicking on the checkbox itself, edit icon, or view icon
if (e.target.closest('.item-selection-checkbox') ||
e.target.closest('.item-edit-icon') ||
@@ -111,19 +145,27 @@ export function MediaItemVideo(props) {
</div>
)}
{editMediaComponent()}
{viewMediaComponent()}
{!isSelectMediaMode && editMediaComponent()}
{!isSelectMediaMode && viewMediaComponent()}
{props.hasMediaViewer ? videoViewerComponent() : thumbnailComponent()}
<UnderThumbWrapper title={props.title} link={props.link}>
{titleComponent()}
{metaComponents()}
{descriptionComponent()}
</UnderThumbWrapper>
</div>
{isSelectMediaMode ? (
<UnderThumbWrapper>
{titleComponent()}
{metaComponents()}
{descriptionComponent()}
</UnderThumbWrapper>
) : (
<UnderThumbWrapper title={props.title} link={props.link}>
{titleComponent()}
{metaComponents()}
{descriptionComponent()}
</UnderThumbWrapper>
)}
{playlistOptionsComponent()}
{playlistOptionsComponent()}
</div>
</div>
);
}
@@ -513,12 +513,6 @@
}
}
.embedded-app {
.viewer-container,
.viewer-info {
width: 100%;
}
}
.viewer-image-container {
position: relative;
@@ -79,6 +79,10 @@ function EditMediaButton(props) {
link = '/edit-media.html';
}
if (link && inEmbeddedApp()) {
link += '&mode=lms_embed_mode';
}
return (
<a href={link} rel="nofollow" title={translateString('Edit media')} className="edit-media-icon">
<i className="material-icons">edit</i>
@@ -94,11 +98,18 @@ export default function ViewerInfoContent(props) {
!PageStore.get('config-enabled').taxonomies.tags || PageStore.get('config-enabled').taxonomies.tags.enabled
? metafield(MediaPageStore.get('media-tags'))
: [];
let mediaCategories = MediaPageStore.get('media-categories');
// Filter to show only LMS courses when in embed mode
if (inEmbeddedApp()) {
mediaCategories = mediaCategories.filter(cat => cat.is_lms_course === true);
}
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'))
? metafield(mediaCategories)
: [];
let summary = MediaPageStore.get('media-summary');
@@ -220,9 +231,13 @@ export default function ViewerInfoContent(props) {
<MediaMetaField
value={categoriesContent}
title={
1 < categoriesContent.length
? translateString('Categories')
: translateString('Category')
inEmbeddedApp()
? (1 < categoriesContent.length
? translateString('Courses')
: translateString('Course'))
: (1 < categoriesContent.length
? translateString('Categories')
: translateString('Category'))
}
id="categories"
/>
@@ -274,7 +289,7 @@ export default function ViewerInfoContent(props) {
</div>
</div>
{!inEmbeddedApp() && <CommentsList />}
<CommentsList />
</div>
);
}
@@ -183,8 +183,7 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
{MemberContext._currentValue.can.shareMedia ? <MediaShareButton isVideo={false} /> : null}
{!MemberContext._currentValue.is.anonymous &&
MemberContext._currentValue.can.saveMedia &&
-1 < PlaylistsContext._currentValue.mediaTypes.indexOf(MediaPageStore.get('media-type')) ? (
MemberContext._currentValue.can.saveMedia ? (
<MediaSaveButton />
) : null}
@@ -95,10 +95,8 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
<MediaShareButton isVideo={true} />
) : null}
{!inEmbeddedApp() &&
!MemberContext._currentValue.is.anonymous &&
MemberContext._currentValue.can.saveMedia &&
-1 < PlaylistsContext._currentValue.mediaTypes.indexOf(MediaPageStore.get('media-type')) ? (
{!MemberContext._currentValue.is.anonymous &&
MemberContext._currentValue.can.saveMedia ? (
<MediaSaveButton />
) : null}
@@ -1,7 +1,4 @@
import React from 'react';
import { MediaPageStore } from '../../utils/stores/';
import { AutoPlay } from './AutoPlay';
import { RelatedMedia } from './RelatedMedia';
import PlaylistView from './PlaylistView';
export default class ViewerSidebar extends React.PureComponent {
@@ -12,8 +9,6 @@ export default class ViewerSidebar extends React.PureComponent {
playlistData: props.playlistData,
isPlaylistPage: !!props.playlistData,
activeItem: 0,
mediaType: MediaPageStore.get('media-type'),
chapters: MediaPageStore.get('media-data')?.chapters
};
if (props.playlistData) {
@@ -28,18 +23,6 @@ export default class ViewerSidebar extends React.PureComponent {
}
}
this.onMediaLoad = this.onMediaLoad.bind(this);
}
componentDidMount() {
MediaPageStore.on('loaded_media_data', this.onMediaLoad);
}
onMediaLoad() {
this.setState({
mediaType: MediaPageStore.get('media-type'),
chapters: MediaPageStore.get('media-data')?.chapter_data || []
});
}
render() {
@@ -47,10 +30,7 @@ export default class ViewerSidebar extends React.PureComponent {
<div className="viewer-sidebar">
{this.state.isPlaylistPage ? (
<PlaylistView activeItem={this.state.activeItem} playlistData={this.props.playlistData} />
) : 'video' === this.state.mediaType || 'audio' === this.state.mediaType ? (
<AutoPlay />
) : null}
<RelatedMedia hideFirst={!this.state.isPlaylistPage} />
</div>
);
}
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { VideoViewerActions } from '../../../utils/actions/';
import { SiteContext, SiteConsumer } from '../../../utils/contexts/';
import { PageStore, MediaPageStore, VideoViewerStore } from '../../../utils/stores/';
import { addClassname, removeClassname, formatInnerLink } from '../../../utils/helpers/';
import { addClassname, removeClassname, formatInnerLink, inEmbeddedApp } from '../../../utils/helpers/';
import { BrowserCache, UpNextLoaderView, MediaDurationInfo } from '../../../utils/classes/';
import {
orderedSupportedVideoFormats,
@@ -176,11 +176,13 @@ export default class VideoViewer extends React.PureComponent {
topLeftHtml = document.createElement('div');
topLeftHtml.setAttribute('class', 'media-links-top-left');
const linkTarget = inEmbeddedApp() || window.location.href.indexOf('lms_embed_mode') > -1 ? '_self' : '_blank';
if (titleLink) {
titleLink.setAttribute('class', 'title-link');
titleLink.setAttribute('href', this.props.data.url);
titleLink.setAttribute('href', this.props.data.url || '#');
titleLink.setAttribute('target', linkTarget);
titleLink.setAttribute('title', this.props.data.title);
titleLink.setAttribute('target', '_blank');
titleLink.innerHTML = this.props.data.title;
}
@@ -191,7 +193,7 @@ export default class VideoViewer extends React.PureComponent {
formatInnerLink(this.props.data.author_profile, this.props.siteUrl)
);
userThumbLink.setAttribute('title', this.props.data.author_name);
userThumbLink.setAttribute('target', '_blank');
userThumbLink.setAttribute('target', linkTarget);
userThumbLink.setAttribute(
'style',
'background-image:url(' +
@@ -411,6 +413,7 @@ export default class VideoViewer extends React.PureComponent {
previewSprite: previewSprite,
subtitlesInfo: this.props.data.subtitles_info,
inEmbed: this.props.inEmbed,
parentMediaBase: this.props.parentMediaBase || null,
showTitle: this.props.showTitle,
showRelated: this.props.showRelated,
showUserAvatar: this.props.showUserAvatar,
@@ -88,7 +88,7 @@ function UploadMediaButton({ user, links }) {
{
link: '/record_screen',
icon: 'videocam',
text: translateString('Record Screen'),
text: translateString('Record'),
},
];
@@ -576,6 +576,7 @@
// Ensure icon buttons are visible on mobile
&.media-search,
&.media-filters-toggle,
&.media-sharing-toggle,
&.media-tags-toggle,
&.media-sorting-toggle {
@media screen and (max-width: 768px) {
@@ -656,30 +657,6 @@
}
}
&.fixed-nav {
.profile-info-nav-wrap {
padding-bottom: $_authorPage-navHeight;
}
.profile-nav {
z-index: 3;
position: fixed;
top: var(--header-height);
left: 0;
right: 0;
@media screen and (min-width: 768px) {
.visible-sidebar & {
padding-left: var(--sidebar-width);
}
.sliding-sidebar & {
transition-property: padding-left;
transition-duration: 0.2s;
}
}
}
}
}
.page-main {
@@ -925,6 +902,13 @@ $-max-width: $-hor-spaces + ( 2 * $item-width ) - 1;
}
}
.mi-sharing-filter-options {
> .active button,
> * button:hover {
background-color: #3b82f6 !important;
}
}
$-hor-spaces: 2 * $side-empty-space;
$-max-width: $-hor-spaces + ( 2 * $item-width ) - 1;
@@ -5,7 +5,7 @@ import { LinksContext, MemberContext, SiteContext } from '../../utils/contexts/'
import { PageStore, ProfilePageStore } from '../../utils/stores/';
import { PageActions, ProfilePageActions } from '../../utils/actions/';
import { CircleIconButton, PopupMain } from '../_shared';
import { translateString } from '../../utils/helpers/';
import { translateString, inEmbeddedApp, inSelectMediaEmbedMode, isSelectMediaMode, isShareMediaDisabled } from '../../utils/helpers/';
class ProfileSearchBar extends React.PureComponent {
constructor(props) {
@@ -372,30 +372,51 @@ class NavMenuInlineTabs extends React.PureComponent {
}
render() {
const isSelectMediaMode = inSelectMediaEmbedMode();
const shareMediaDisabled = isShareMediaDisabled();
// Append action=select_media to links when in select mode
const mediaLink = isSelectMediaMode
? `${LinksContext._currentValue.profile.media}${LinksContext._currentValue.profile.media.includes('?') ? '&' : '?'}action=select_media`
: LinksContext._currentValue.profile.media;
const sharedByMeLink = isSelectMediaMode
? `${LinksContext._currentValue.profile.shared_by_me}${LinksContext._currentValue.profile.shared_by_me.includes('?') ? '&' : '?'}action=select_media`
: LinksContext._currentValue.profile.shared_by_me;
const sharedWithMeBase = shareMediaDisabled
? `${LinksContext._currentValue.profile.shared_with_me}${LinksContext._currentValue.profile.shared_with_me.includes('?') ? '&' : '?'}share_media=0`
: LinksContext._currentValue.profile.shared_with_me;
const sharedWithMeLink = isSelectMediaMode
? `${sharedWithMeBase}${sharedWithMeBase.includes('?') ? '&' : '?'}action=select_media`
: sharedWithMeBase;
return (
<nav ref="tabsNav" className="profile-nav items-list-outer list-inline list-slider">
<div className="profile-nav-inner items-list-outer">
{this.state.displayPrev ? this.previousBtn : null}
<ul className="items-list-wrap" ref="itemsListWrap">
<InlineTab
id="about"
isActive={'about' === this.props.type}
label={translateString('About')}
link={LinksContext._currentValue.profile.about}
/>
{!isSelectMediaMode && !inEmbeddedApp() ? (
<InlineTab
id="about"
isActive={'about' === this.props.type}
label={translateString('About')}
link={LinksContext._currentValue.profile.about}
/>
) : null}
<InlineTab
id="media"
isActive={'media' === this.props.type}
label={translateString(this.userIsAuthor ? 'Media I own' : 'Media')}
link={LinksContext._currentValue.profile.media}
link={mediaLink}
/>
{this.userIsAuthor ? (
<InlineTab
id="shared_by_me"
isActive={'shared_by_me' === this.props.type}
label={translateString('Shared by me')}
link={LinksContext._currentValue.profile.shared_by_me}
link={sharedByMeLink}
/>
) : null}
{this.userIsAuthor ? (
@@ -403,11 +424,11 @@ class NavMenuInlineTabs extends React.PureComponent {
id="shared_with_me"
isActive={'shared_with_me' === this.props.type}
label={translateString('Shared with me')}
link={LinksContext._currentValue.profile.shared_with_me}
link={sharedWithMeLink}
/>
) : null}
{MemberContext._currentValue.can.saveMedia ? (
{!isSelectMediaMode && MemberContext._currentValue.can.saveMedia ? (
<InlineTab
id="playlists"
isActive={'playlists' === this.props.type}
@@ -474,6 +495,39 @@ class NavMenuInlineTabs extends React.PureComponent {
</span>
</li>
) : null}
{this.props.onToggleSharingClick &&
['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
<li className="media-sharing-toggle">
<span
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
position: 'relative',
}}
onClick={this.props.onToggleSharingClick}
title={translateString('Shared with')}
>
<CircleIconButton buttonShadow={false}>
<i className="material-icons">people</i>
</CircleIconButton>
{this.props.hasActiveSharing ? (
<span
style={{
position: 'absolute',
top: '8px',
right: '8px',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)',
border: '2px solid white',
}}
></span>
) : null}
</span>
</li>
) : null}
{this.props.onToggleTagsClick &&
['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
<li className="media-tags-toggle">
@@ -553,9 +607,11 @@ NavMenuInlineTabs.propTypes = {
type: PropTypes.string.isRequired,
onQueryChange: PropTypes.func,
onToggleFiltersClick: PropTypes.func,
onToggleSharingClick: PropTypes.func,
onToggleTagsClick: PropTypes.func,
onToggleSortingClick: PropTypes.func,
hasActiveFilters: PropTypes.bool,
hasActiveSharing: PropTypes.bool,
hasActiveTags: PropTypes.bool,
hasActiveSort: PropTypes.bool,
};
@@ -606,12 +662,6 @@ export default function ProfilePagesHeader(props) {
const profilePageHeaderRef = useRef(null);
const profileNavRef = useRef(null);
const [fixedNav, setFixedNav] = useState(false);
const positions = {
profileNavTop: 0,
};
const userIsAdmin = !MemberContext._currentValue.is.anonymous && MemberContext._currentValue.is.admin;
const userIsAuthor =
!MemberContext._currentValue.is.anonymous &&
@@ -623,18 +673,6 @@ export default function ProfilePagesHeader(props) {
userIsAuthor ||
(!MemberContext._currentValue.is.anonymous && MemberContext._currentValue.can.deleteProfile);
function updateProfileNavTopPosition() {
positions.profileHeaderTop = profilePageHeaderRef.current.offsetTop;
positions.profileNavTop =
positions.profileHeaderTop +
profilePageHeaderRef.current.offsetHeight -
profileNavRef.current.refs.tabsNav.offsetHeight;
}
function updateFixedNavPosition() {
setFixedNav(positions.profileHeaderTop + window.scrollY > positions.profileNavTop);
}
function cancelProfileRemoval() {
popupContentRef.current.toggle();
}
@@ -669,42 +707,26 @@ export default function ProfilePagesHeader(props) {
}
}
function onWindowResize() {
updateProfileNavTopPosition();
updateFixedNavPosition();
}
function onWindowScroll() {
updateFixedNavPosition();
}
useEffect(() => {
if (userCanDeleteProfile) {
ProfilePageStore.on('profile_delete', onProfileDelete);
ProfilePageStore.on('profile_delete_fail', onProfileDeleteFail);
}
PageStore.on('resize', onWindowResize);
PageStore.on('changed_page_sidebar_visibility', onWindowResize);
PageStore.on('window_scroll', onWindowScroll);
updateProfileNavTopPosition();
updateFixedNavPosition();
return () => {
if (userCanDeleteProfile) {
ProfilePageStore.removeListener('profile_delete', onProfileDelete);
ProfilePageStore.removeListener('profile_delete_fail', onProfileDeleteFail);
}
PageStore.removeListener('resize', onWindowResize);
PageStore.removeListener('changed_page_sidebar_visibility', onWindowResize);
PageStore.removeListener('window_scroll', onWindowScroll);
};
}, []);
return (
<div ref={profilePageHeaderRef} className={'profile-page-header' + (fixedNav ? ' fixed-nav' : '')}>
<div
ref={profilePageHeaderRef}
className={'profile-page-header'}
{...(isSelectMediaMode() ? { 'data-action': 'select_media' } : {})}
>
{!props.hideChannelBanner && (
<span className="profile-banner-wrap">
{props.author.banner_thumbnail_url ? (
@@ -768,7 +790,7 @@ export default function ProfilePagesHeader(props) {
)}
<div className="profile-info-nav-wrap">
{props.author.thumbnail_url || props.author.name ? (
{!inEmbeddedApp() && (props.author.thumbnail_url || props.author.name) ? (
<div className="profile-info">
<div className="profile-info-inner">
<div>
@@ -793,9 +815,11 @@ export default function ProfilePagesHeader(props) {
type={props.type}
onQueryChange={props.onQueryChange}
onToggleFiltersClick={props.onToggleFiltersClick}
onToggleSharingClick={userIsAuthor ? props.onToggleSharingClick : undefined}
onToggleTagsClick={props.onToggleTagsClick}
onToggleSortingClick={props.onToggleSortingClick}
hasActiveFilters={props.hasActiveFilters}
hasActiveSharing={props.hasActiveSharing}
hasActiveTags={props.hasActiveTags}
hasActiveSort={props.hasActiveSort}
/>
@@ -809,9 +833,11 @@ ProfilePagesHeader.propTypes = {
type: PropTypes.string.isRequired,
onQueryChange: PropTypes.func,
onToggleFiltersClick: PropTypes.func,
onToggleSharingClick: PropTypes.func,
onToggleTagsClick: PropTypes.func,
onToggleSortingClick: PropTypes.func,
hasActiveFilters: PropTypes.bool,
hasActiveSharing: PropTypes.bool,
hasActiveTags: PropTypes.bool,
hasActiveSort: PropTypes.bool,
};
@@ -0,0 +1,103 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { PageStore } from '../../utils/stores/';
import { FilterOptions } from '../_shared';
import { translateString } from '../../utils/helpers/';
import '../management-table/ManageItemList-filters.scss';
export function ProfileMediaSharing(props) {
const [isHidden, setIsHidden] = useState(props.hidden);
const containerRef = useRef(null);
const innerContainerRef = useRef(null);
function onWindowResize() {
if (!isHidden && containerRef.current && innerContainerRef.current) {
containerRef.current.style.height = 24 + innerContainerRef.current.offsetHeight + 'px';
}
}
useEffect(() => {
setIsHidden(props.hidden);
onWindowResize();
}, [props.hidden, props.sharedUsers, props.sharedGroups]);
useEffect(() => {
PageStore.on('window_resize', onWindowResize);
return () => PageStore.removeListener('window_resize', onWindowResize);
}, []);
function onUserSelect(ev) {
const username = ev.currentTarget.getAttribute('value');
const newValue = (username === 'all' || username === props.selectedSharingValue) ? null : username;
props.onSharingSelect(newValue ? 'user' : null, newValue);
}
function onGroupSelect(ev) {
const name = ev.currentTarget.getAttribute('value');
const newValue = (name === 'all' || name === props.selectedSharingValue) ? null : name;
props.onSharingSelect(newValue ? 'group' : null, newValue);
}
const hasUsers = props.sharedUsers && props.sharedUsers.length > 0;
const hasGroups = props.sharedGroups && props.sharedGroups.length > 0;
const usersOptions = [
{ id: 'all', title: translateString('All') },
...(props.sharedUsers || []).map((u) => ({ id: u.username, title: u.name })),
];
const groupsOptions = [
{ id: 'all', title: translateString('All') },
...(props.sharedGroups || []).map((g) => ({ id: g.name, title: g.name })),
];
const selectedUser = props.selectedSharingType === 'user' ? props.selectedSharingValue : 'all';
const selectedGroup = props.selectedSharingType === 'group' ? props.selectedSharingValue : 'all';
return (
<div ref={containerRef} className={'mi-filters-row' + (isHidden ? ' hidden' : '')}>
<div ref={innerContainerRef} className="mi-filters-row-inner">
{hasUsers ? (
<div className="mi-filter mi-filter-full-width">
<div className="mi-filter-title">{translateString(props.mode === 'shared_with_me' ? 'USERS SHARING' : 'SHARED WITH USERS')}</div>
<div className="mi-filter-options mi-filter-options-horizontal mi-sharing-filter-options">
<FilterOptions id="shared_user" options={usersOptions} selected={selectedUser} onSelect={onUserSelect} />
</div>
</div>
) : null}
{hasGroups ? (
<div className="mi-filter mi-filter-full-width">
<div className="mi-filter-title">{translateString(props.mode === 'shared_with_me' ? 'GROUPS SHARING' : 'SHARED WITH GROUPS')}</div>
<div className="mi-filter-options mi-filter-options-horizontal mi-sharing-filter-options">
<FilterOptions id="shared_group" options={groupsOptions} selected={selectedGroup} onSelect={onGroupSelect} />
</div>
</div>
) : null}
{!hasUsers && !hasGroups ? (
<div className="mi-filter mi-filter-full-width">
<div className="mi-filter-title">{translateString('NOT SHARED WITH ANYONE')}</div>
</div>
) : null}
</div>
</div>
);
}
ProfileMediaSharing.propTypes = {
hidden: PropTypes.bool,
mode: PropTypes.string,
sharedUsers: PropTypes.array,
sharedGroups: PropTypes.array,
onSharingSelect: PropTypes.func,
selectedSharingType: PropTypes.string,
selectedSharingValue: PropTypes.string,
};
ProfileMediaSharing.defaultProps = {
hidden: false,
mode: null,
sharedUsers: [],
sharedGroups: [],
selectedSharingType: null,
selectedSharingValue: null,
};
@@ -3,14 +3,14 @@ import { ApiUrlConsumer } from '../utils/contexts/';
import { MediaListWrapper } from '../components/MediaListWrapper';
import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListAsync.jsx';
import { Page } from './Page';
import { translateString } from '../utils/helpers/';
import { translateString, inEmbeddedApp } from '../utils/helpers/';
interface CategoriesPageProps {
id?: string;
title?: string;
}
export const CategoriesPage: React.FC<CategoriesPageProps> = ({ id = 'categories', title = translateString('Categories') }) => (
export const CategoriesPage: React.FC<CategoriesPageProps> = ({ id = 'categories', title = inEmbeddedApp() ? translateString('Courses') : translateString('Categories') }) => (
<Page id={id}>
<ApiUrlConsumer>
{(apiUrl) => (
+9 -4
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, CSSProperties } from 'react';
import { SiteConsumer } from '../utils/contexts/';
import { MediaPageStore } from '../utils/stores/';
import { MediaPageActions } from '../utils/actions/';
import { getParentMediaBase } from '../utils/helpers/';
import VideoViewer from '../components/media-viewer/VideoViewer';
const wrapperStyles = {
@@ -72,16 +73,20 @@ export const EmbedPage: React.FC = () => {
const urlTimestamp = urlParams.get('t');
const timestamp = urlTimestamp ? parseInt(urlTimestamp, 10) : null;
const parentMediaBase = getParentMediaBase();
return (
<VideoViewer
data={MediaPageStore.get('media-data')}
siteUrl={site.url}
containerStyles={containerStyles}
<VideoViewer
data={MediaPageStore.get('media-data')}
siteUrl={site.url}
containerStyles={containerStyles}
inEmbed={true}
showTitle={showTitle}
showRelated={showRelated}
showUserAvatar={showUserAvatar}
linkTitle={linkTitle}
timestamp={timestamp}
parentMediaBase={parentMediaBase}
/>
);
}}
@@ -8,7 +8,7 @@ import { PageStore, ProfilePageStore } from '../utils/stores/';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
import { MediaListRow } from '../components/MediaListRow';
import { ProfileMediaPage } from './ProfileMediaPage';
import { ProfileMediaPageBase } from './ProfileMediaPage';
class ChannelContactForm extends React.PureComponent {
constructor(props) {
@@ -149,7 +149,7 @@ class ChannelContactForm extends React.PureComponent {
}
}
export class ProfileAboutPage extends ProfileMediaPage {
export class ProfileAboutPage extends ProfileMediaPageBase {
constructor(props) {
super(props, 'author-about');
@@ -7,9 +7,9 @@ import { MediaListWrapper } from '../components/MediaListWrapper';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListAsync.jsx';
import { ProfileMediaPage } from './ProfileMediaPage';
import { ProfileMediaPageBase } from './ProfileMediaPage';
export class ProfileHistoryPage extends ProfileMediaPage {
export class ProfileHistoryPage extends ProfileMediaPageBase {
constructor(props) {
super(props, 'author-history');
@@ -7,9 +7,9 @@ import { MediaListWrapper } from '../components/MediaListWrapper';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListAsync';
import { ProfileMediaPage } from './ProfileMediaPage';
import { ProfileMediaPageBase } from './ProfileMediaPage';
export class ProfileLikedPage extends ProfileMediaPage {
export class ProfileLikedPage extends ProfileMediaPageBase {
constructor(props) {
super(props, 'author-liked');
File diff suppressed because it is too large Load Diff
@@ -6,9 +6,9 @@ import { MediaListWrapper } from '../components/MediaListWrapper';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListAsync.jsx';
import { ProfileMediaPage } from './ProfileMediaPage';
import { ProfileMediaPageBase } from './ProfileMediaPage';
export class ProfilePlaylistsPage extends ProfileMediaPage {
export class ProfilePlaylistsPage extends ProfileMediaPageBase {
constructor(props) {
super(props, 'author-playlists');
@@ -9,9 +9,10 @@ import ProfilePagesContent from '../components/profile-page/ProfilePagesContent'
import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListAsync';
import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFilters';
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
import { ProfileMediaSharing } from '../components/search-filters/ProfileMediaSharing';
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
import { BulkActionsModals } from '../components/BulkActionsModals';
import { inEmbeddedApp, translateString } from '../utils/helpers';
import { inEmbeddedApp, inSelectMediaEmbedMode } from '../utils/helpers';
import { withBulkActions } from '../utils/hoc/withBulkActions';
import { Page } from './_Page';
@@ -47,10 +48,16 @@ class ProfileSharedByMePage extends Page {
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: true,
hiddenSharing: true,
filterArgs: '',
availableTags: [],
selectedTag: 'all',
selectedSort: 'date_added_desc',
selectedMedia: new Set(), // For select media mode
sharedUsers: [],
sharedGroups: [],
selectedSharingType: null,
selectedSharingValue: null,
};
this.authorDataLoad = this.authorDataLoad.bind(this);
@@ -60,10 +67,13 @@ class ProfileSharedByMePage extends Page {
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
this.onToggleSharingClick = this.onToggleSharingClick.bind(this);
this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
this.onTagSelect = this.onTagSelect.bind(this);
this.onSortSelect = this.onSortSelect.bind(this);
this.onSharingSelect = this.onSharingSelect.bind(this);
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
this.handleMediaSelection = this.handleMediaSelection.bind(this);
ProfilePageStore.on('load-author-data', this.authorDataLoad);
}
@@ -175,6 +185,7 @@ class ProfileSharedByMePage extends Page {
hiddenFilters: !this.state.hiddenFilters,
hiddenTags: true,
hiddenSorting: true,
hiddenSharing: true,
});
}
@@ -183,6 +194,7 @@ class ProfileSharedByMePage extends Page {
hiddenFilters: true,
hiddenTags: !this.state.hiddenTags,
hiddenSorting: true,
hiddenSharing: true,
});
}
@@ -191,6 +203,16 @@ class ProfileSharedByMePage extends Page {
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: !this.state.hiddenSorting,
hiddenSharing: true,
});
}
onToggleSharingClick() {
this.setState({
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: true,
hiddenSharing: !this.state.hiddenSharing,
});
}
@@ -203,6 +225,8 @@ class ProfileSharedByMePage extends Page {
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
sort_by: this.state.selectedSort,
tag: tag,
sharing_type: this.state.selectedSharingType,
sharing_value: this.state.selectedSharingValue,
});
});
}
@@ -216,6 +240,23 @@ class ProfileSharedByMePage extends Page {
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
sort_by: sortBy,
tag: this.state.selectedTag,
sharing_type: this.state.selectedSharingType,
sharing_value: this.state.selectedSharingValue,
});
});
}
onSharingSelect(type, value) {
this.setState({ selectedSharingType: type, selectedSharingValue: value }, () => {
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: this.state.selectedTag,
sharing_type: type,
sharing_value: value,
});
});
}
@@ -229,6 +270,8 @@ class ProfileSharedByMePage extends Page {
sort_by: null,
ordering: null,
t: null,
shared_user: null,
shared_group: null,
};
switch (updatedArgs.media_type) {
@@ -290,6 +333,12 @@ class ProfileSharedByMePage extends Page {
args.t = updatedArgs.tag;
}
if (updatedArgs.sharing_type === 'user' && updatedArgs.sharing_value) {
args.shared_user = updatedArgs.sharing_value;
} else if (updatedArgs.sharing_type === 'group' && updatedArgs.sharing_value) {
args.shared_group = updatedArgs.sharing_value;
}
const newArgs = [];
for (let arg in args) {
@@ -341,12 +390,63 @@ class ProfileSharedByMePage extends Page {
.filter((tag) => tag);
this.setState({ availableTags: tags });
}
if (responseData && responseData.shared_users !== undefined) {
this.setState({
sharedUsers: responseData.shared_users || [],
sharedGroups: responseData.shared_groups || [],
});
}
}
handleMediaSelection(mediaId, isSelected) {
const isSelectMediaMode = inSelectMediaEmbedMode();
this.setState((prevState) => {
const newSelectedMedia = new Set();
// In select media mode, only allow single selection
if (isSelectMediaMode) {
if (isSelected) {
newSelectedMedia.add(mediaId);
console.log('Selected media item:', mediaId);
// Send postMessage to parent window (Moodle TinyMCE plugin)
if (window.parent !== window) {
// Construct the embed URL
const baseUrl = window.location.origin;
const embedUrl = `${baseUrl}/embed?m=${mediaId}`;
// Send message in the format expected by the Moodle plugin
window.parent.postMessage({
type: 'videoSelected',
embedUrl: embedUrl,
videoId: mediaId
}, '*');
console.log('Sent postMessage to parent:', { embedUrl, videoId: mediaId });
}
}
} else {
// Normal mode: should not reach here as bulk actions handle this
newSelectedMedia.clear();
prevState.selectedMedia.forEach((id) => newSelectedMedia.add(id));
if (isSelected) {
newSelectedMedia.add(mediaId);
} else {
newSelectedMedia.delete(mediaId);
}
}
return { selectedMedia: newSelectedMedia };
});
}
pageContent() {
const authorData = ProfilePageStore.get('author-data');
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
const isSelectMediaMode = inSelectMediaEmbedMode();
// Check if any filters are active
const hasActiveFilters =
@@ -366,23 +466,27 @@ class ProfileSharedByMePage extends Page {
onToggleFiltersClick={this.onToggleFiltersClick}
onToggleTagsClick={this.onToggleTagsClick}
onToggleSortingClick={this.onToggleSortingClick}
onToggleSharingClick={this.onToggleSharingClick}
hasActiveFilters={hasActiveFilters}
hasActiveTags={this.state.selectedTag !== 'all'}
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
hasActiveSharing={!!this.state.selectedSharingValue}
hideChannelBanner={inEmbeddedApp()}
/>
) : null,
this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent">
<MediaListWrapper
title={this.state.title}
title={inEmbeddedApp() ? undefined : this.state.title}
className="items-list-ver"
showBulkActions={isMediaAuthor}
selectedCount={this.props.bulkActions.selectedMedia.size}
totalCount={this.props.bulkActions.availableMediaIds.length}
style={inEmbeddedApp() ? { marginTop: '24px' } : undefined}
showBulkActions={!isSelectMediaMode && isMediaAuthor}
selectedCount={isSelectMediaMode ? this.state.selectedMedia.size : this.props.bulkActions.selectedMedia.size}
totalCount={isSelectMediaMode ? 0 : this.props.bulkActions.availableMediaIds.length}
onBulkAction={this.props.bulkActions.handleBulkAction}
onSelectAll={this.props.bulkActions.handleSelectAll}
onDeselectAll={this.props.bulkActions.handleDeselectAll}
hasContributorCourses={this.props.bulkActions.hasContributorCourses}
>
<ProfileMediaFilters
hidden={this.state.hiddenFilters}
@@ -395,20 +499,28 @@ class ProfileSharedByMePage extends Page {
onTagSelect={this.onTagSelect}
/>
<ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} />
<ProfileMediaSharing
hidden={this.state.hiddenSharing}
sharedUsers={this.state.sharedUsers}
sharedGroups={this.state.sharedGroups}
onSharingSelect={this.onSharingSelect}
selectedSharingType={this.state.selectedSharingType}
selectedSharingValue={this.state.selectedSharingValue}
/>
<LazyLoadItemListAsync
key={`${this.state.requestUrl}-${this.props.bulkActions.listKey}`}
key={isSelectMediaMode ? this.state.requestUrl : `${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}
canEdit={!isSelectMediaMode && 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}
showSelection={isMediaAuthor || isSelectMediaMode}
hasAnySelection={isSelectMediaMode ? this.state.selectedMedia.size > 0 : this.props.bulkActions.selectedMedia.size > 0}
selectedMedia={isSelectMediaMode ? this.state.selectedMedia : this.props.bulkActions.selectedMedia}
onMediaSelection={isSelectMediaMode ? this.handleMediaSelection : this.props.bulkActions.handleMediaSelection}
onItemsUpdate={!isSelectMediaMode ? this.props.bulkActions.handleItemsUpdate : undefined}
/>
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
<EmptySharedByMe name={this.state.author.name} />
@@ -416,7 +528,7 @@ class ProfileSharedByMePage extends Page {
</MediaListWrapper>
</ProfilePagesContent>
) : null,
this.state.author && isMediaAuthor ? (
this.state.author && isMediaAuthor && !isSelectMediaMode ? (
<BulkActionsModals
key="BulkActionsModals"
{...this.props.bulkActions}
@@ -443,6 +555,10 @@ class ProfileSharedByMePage extends Page {
onTagModalCancel={this.props.bulkActions.handleTagModalCancel}
onTagModalSuccess={this.props.bulkActions.handleTagModalSuccess}
onTagModalError={this.props.bulkActions.handleTagModalError}
showCourseCleanupModal={this.props.bulkActions.showCourseCleanupModal}
onCourseCleanupModalCancel={this.props.bulkActions.handleCourseCleanupModalCancel}
onCourseCleanupModalSuccess={this.props.bulkActions.handleCourseCleanupModalSuccess}
onCourseCleanupModalError={this.props.bulkActions.handleCourseCleanupModalError}
/>
) : null,
];
@@ -9,8 +9,9 @@ import ProfilePagesContent from '../components/profile-page/ProfilePagesContent'
import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListAsync';
import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFilters';
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
import { ProfileMediaSharing } from '../components/search-filters/ProfileMediaSharing';
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
import { inEmbeddedApp, translateString } from '../utils/helpers';
import { inEmbeddedApp, inSelectMediaEmbedMode, isShareMediaDisabled } from '../utils/helpers';
import { Page } from './_Page';
@@ -45,10 +46,16 @@ export class ProfileSharedWithMePage extends Page {
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: true,
hiddenSharing: true,
filterArgs: '',
availableTags: [],
selectedTag: 'all',
selectedSort: 'date_added_desc',
selectedMedia: new Set(), // For select media mode
sharedUsers: [],
sharedGroups: [],
selectedSharingType: null,
selectedSharingValue: null,
};
this.authorDataLoad = this.authorDataLoad.bind(this);
@@ -58,10 +65,13 @@ export class ProfileSharedWithMePage extends Page {
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
this.onToggleSharingClick = this.onToggleSharingClick.bind(this);
this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
this.onTagSelect = this.onTagSelect.bind(this);
this.onSortSelect = this.onSortSelect.bind(this);
this.onSharingSelect = this.onSharingSelect.bind(this);
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
this.handleMediaSelection = this.handleMediaSelection.bind(this);
ProfilePageStore.on('load-author-data', this.authorDataLoad);
}
@@ -76,6 +86,7 @@ export class ProfileSharedWithMePage extends Page {
let requestUrl = this.state.requestUrl;
if (author) {
const excludeLtiEmbed = isShareMediaDisabled() ? '&exclude_lti_embed=1' : '';
if (this.state.query) {
requestUrl =
ApiUrlContext._currentValue.media +
@@ -83,6 +94,7 @@ export class ProfileSharedWithMePage extends Page {
author.id +
'&show=shared_with_me&q=' +
encodeURIComponent(this.state.query) +
excludeLtiEmbed +
this.state.filterArgs;
} else {
requestUrl =
@@ -90,6 +102,7 @@ export class ProfileSharedWithMePage extends Page {
'?author=' +
author.id +
'&show=shared_with_me' +
excludeLtiEmbed +
this.state.filterArgs;
}
}
@@ -173,6 +186,7 @@ export class ProfileSharedWithMePage extends Page {
hiddenFilters: !this.state.hiddenFilters,
hiddenTags: true,
hiddenSorting: true,
hiddenSharing: true,
});
}
@@ -181,6 +195,7 @@ export class ProfileSharedWithMePage extends Page {
hiddenFilters: true,
hiddenTags: !this.state.hiddenTags,
hiddenSorting: true,
hiddenSharing: true,
});
}
@@ -189,6 +204,16 @@ export class ProfileSharedWithMePage extends Page {
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: !this.state.hiddenSorting,
hiddenSharing: true,
});
}
onToggleSharingClick() {
this.setState({
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: true,
hiddenSharing: !this.state.hiddenSharing,
});
}
@@ -201,6 +226,8 @@ export class ProfileSharedWithMePage extends Page {
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
sort_by: this.state.selectedSort,
tag: tag,
sharing_type: this.state.selectedSharingType,
sharing_value: this.state.selectedSharingValue,
});
});
}
@@ -214,6 +241,23 @@ export class ProfileSharedWithMePage extends Page {
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
sort_by: sortBy,
tag: this.state.selectedTag,
sharing_type: this.state.selectedSharingType,
sharing_value: this.state.selectedSharingValue,
});
});
}
onSharingSelect(type, value) {
this.setState({ selectedSharingType: type, selectedSharingValue: value }, () => {
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: this.state.selectedTag,
sharing_type: type,
sharing_value: value,
});
});
}
@@ -227,6 +271,8 @@ export class ProfileSharedWithMePage extends Page {
sort_by: null,
ordering: null,
t: null,
shared_user: null,
shared_group: null,
};
switch (updatedArgs.media_type) {
@@ -288,6 +334,12 @@ export class ProfileSharedWithMePage extends Page {
args.t = updatedArgs.tag;
}
if (updatedArgs.sharing_type === 'user' && updatedArgs.sharing_value) {
args.shared_user = updatedArgs.sharing_value;
} else if (updatedArgs.sharing_type === 'group' && updatedArgs.sharing_value) {
args.shared_group = updatedArgs.sharing_value;
}
const newArgs = [];
for (let arg in args) {
@@ -339,12 +391,63 @@ export class ProfileSharedWithMePage extends Page {
.filter((tag) => tag);
this.setState({ availableTags: tags });
}
if (responseData && responseData.shared_users !== undefined) {
this.setState({
sharedUsers: responseData.shared_users || [],
sharedGroups: responseData.shared_groups || [],
});
}
}
handleMediaSelection(mediaId, isSelected) {
const isSelectMediaMode = inSelectMediaEmbedMode();
this.setState((prevState) => {
const newSelectedMedia = new Set();
// In select media mode, only allow single selection
if (isSelectMediaMode) {
if (isSelected) {
newSelectedMedia.add(mediaId);
console.log('Selected media item:', mediaId);
// Send postMessage to parent window (Moodle TinyMCE plugin)
if (window.parent !== window) {
// Construct the embed URL
const baseUrl = window.location.origin;
const embedUrl = `${baseUrl}/embed?m=${mediaId}`;
// Send message in the format expected by the Moodle plugin
window.parent.postMessage({
type: 'videoSelected',
embedUrl: embedUrl,
videoId: mediaId
}, '*');
console.log('Sent postMessage to parent:', { embedUrl, videoId: mediaId });
}
}
} else {
// Normal mode: no selection UI in this page normally
newSelectedMedia.clear();
prevState.selectedMedia.forEach((id) => newSelectedMedia.add(id));
if (isSelected) {
newSelectedMedia.add(mediaId);
} else {
newSelectedMedia.delete(mediaId);
}
}
return { selectedMedia: newSelectedMedia };
});
}
pageContent() {
const authorData = ProfilePageStore.get('author-data');
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
const isSelectMediaMode = inSelectMediaEmbedMode();
// Check if any filters are active
const hasActiveFilters =
@@ -364,15 +467,21 @@ export class ProfileSharedWithMePage extends Page {
onToggleFiltersClick={this.onToggleFiltersClick}
onToggleTagsClick={this.onToggleTagsClick}
onToggleSortingClick={this.onToggleSortingClick}
onToggleSharingClick={this.onToggleSharingClick}
hasActiveFilters={hasActiveFilters}
hasActiveTags={this.state.selectedTag !== 'all'}
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
hasActiveSharing={!!this.state.selectedSharingValue}
hideChannelBanner={inEmbeddedApp()}
/>
) : null,
this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent">
<MediaListWrapper title={this.state.title} className="items-list-ver">
<MediaListWrapper
title={inEmbeddedApp() ? undefined : this.state.title}
className="items-list-ver"
style={inEmbeddedApp() ? { marginTop: '24px' } : undefined}
>
<ProfileMediaFilters
hidden={this.state.hiddenFilters}
tags={this.state.availableTags}
@@ -384,6 +493,15 @@ export class ProfileSharedWithMePage extends Page {
onTagSelect={this.onTagSelect}
/>
<ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} />
<ProfileMediaSharing
hidden={this.state.hiddenSharing}
mode="shared_with_me"
sharedUsers={this.state.sharedUsers}
sharedGroups={this.state.sharedGroups}
onSharingSelect={this.onSharingSelect}
selectedSharingType={this.state.selectedSharingType}
selectedSharingValue={this.state.selectedSharingValue}
/>
<LazyLoadItemListAsync
key={this.state.requestUrl}
requestUrl={this.state.requestUrl}
@@ -393,6 +511,10 @@ export class ProfileSharedWithMePage extends Page {
hideDate={!PageStore.get('config-media-item').displayPublishDate}
canEdit={false}
onResponseDataLoaded={this.onResponseDataLoaded}
showSelection={isSelectMediaMode}
hasAnySelection={this.state.selectedMedia.size > 0}
selectedMedia={this.state.selectedMedia}
onMediaSelection={this.handleMediaSelection}
/>
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
<EmptySharedWithMe name={this.state.author.name} />
+2 -2
View File
@@ -7,7 +7,7 @@ import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListA
import { SearchMediaFiltersRow } from '../components/search-filters/SearchMediaFiltersRow';
import { SearchResultsFilters } from '../components/search-filters/SearchResultsFilters';
import { Page } from './_Page';
import { translateString } from '../utils/helpers/';
import { translateString, inEmbeddedApp } from '../utils/helpers/';
export class SearchPage extends Page {
constructor(props) {
@@ -115,7 +115,7 @@ export class SearchPage extends Page {
} else {
if (this.state.searchCategories) {
title = null === this.state.resultsCount || 0 === this.state.resultsCount ? 'No' : this.state.resultsCount;
title += ' ' + translateString('media in category') + ' "' + this.state.searchCategories + '"';
title += ' ' + translateString(inEmbeddedApp() ? 'media in course' : 'media in category') + ' "' + this.state.searchCategories + '"';
} else if (this.state.searchTags) {
title = null === this.state.resultsCount || 0 === this.state.resultsCount ? 'No' : this.state.resultsCount;
title += ' ' + translateString('media in tag') + ' "' + this.state.searchTags + '"';
+10 -15
View File
@@ -1,7 +1,6 @@
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';
@@ -87,22 +86,18 @@ export class _MediaPage extends Page {
{!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,
<ViewerSidebar
key="viewer-sidebar"
mediaId={MediaPageStore.get('media-id')}
playlistData={MediaPageStore.get('playlist-data')}
/>,
]
: [
!inEmbeddedApp() && this.state.pagePlaylistLoaded ? (
<ViewerSidebar
key="viewer-sidebar"
mediaId={MediaPageStore.get('media-id')}
playlistData={MediaPageStore.get('playlist-data')}
/>
) : null,
<ViewerSidebar
key="viewer-sidebar"
mediaId={MediaPageStore.get('media-id')}
playlistData={MediaPageStore.get('playlist-data')}
/>,
<ViewerInfo key="viewer-info" />,
]}
</div>
@@ -2,7 +2,6 @@ import React from 'react';
// FIXME: 'VideoViewerStore' is used only in case of video media, but is included in every media page code.
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';
@@ -104,7 +103,7 @@ export class _VideoMediaPage extends Page {
{!this.state.wideLayout || (this.state.isVideoMedia && this.state.theaterMode)
? [
<ViewerInfoVideo key="viewer-info" />,
!inEmbeddedApp() && this.state.pagePlaylistLoaded ? (
this.state.pagePlaylistLoaded ? (
<ViewerSidebar
key="viewer-sidebar"
mediaId={MediaPageStore.get('media-id')}
@@ -113,7 +112,7 @@ export class _VideoMediaPage extends Page {
) : null,
]
: [
!inEmbeddedApp() && this.state.pagePlaylistLoaded ? (
this.state.pagePlaylistLoaded ? (
<ViewerSidebar
key="viewer-sidebar"
mediaId={MediaPageStore.get('media-id')}
@@ -45,12 +45,14 @@ export const LayoutProvider = ({ children }) => {
const site = useContext(SiteContext);
const cache = new BrowserCache('MediaCMS[' + site.id + '][layout]', 86400);
const isMediaPage = useMemo(() => PageStore.get('current-page') === 'media', []);
const isMediaPage = useMemo(() => PageStore.get('current-page') === 'media' || window.MediaCMS?.mediaId !== undefined, []);
const isEmbeddedApp = useMemo(() => inEmbeddedApp(), []);
const enabledSidebar = Boolean(document.getElementById('app-sidebar') || document.querySelector('.page-sidebar'));
const [visibleSidebar, setVisibleSidebar] = useState(cache.get('visible-sidebar'));
const [visibleSidebar, setVisibleSidebar] = useState(
isMediaPage || isEmbeddedApp ? false : cache.get('visible-sidebar')
);
const [visibleMobileSearch, setVisibleMobileSearch] = useState(false);
const toggleMobileSearch = () => {
@@ -3,18 +3,100 @@ export function inEmbeddedApp() {
const params = new URL(globalThis.location.href).searchParams;
const mode = params.get('mode');
if (mode === 'embed_mode') {
sessionStorage.setItem('media_cms_embed_mode', 'true');
if (mode === 'lms_embed_mode') {
sessionStorage.setItem('lms_embed_mode', 'true');
return true;
}
if (mode === 'standard') {
sessionStorage.removeItem('media_cms_embed_mode');
sessionStorage.removeItem('lms_embed_mode');
return false;
}
return sessionStorage.getItem('media_cms_embed_mode') === 'true';
return sessionStorage.getItem('lms_embed_mode') === 'true';
} catch (e) {
return false;
}
}
export function isShareMediaDisabled(): boolean {
try {
const params = new URL(globalThis.location.href).searchParams;
const shareMedia = params.get('share_media');
const mode = params.get('mode');
if (shareMedia === '0') {
sessionStorage.setItem('lms_share_media_disabled', 'true');
return true;
}
// Fresh LTI landing (mode=lms_embed_mode in URL) without share_media=0
// means sharing is enabled — clear any stale disabled flag.
if (shareMedia === '1' || mode === 'lms_embed_mode') {
sessionStorage.removeItem('lms_share_media_disabled');
return false;
}
return sessionStorage.getItem('lms_share_media_disabled') === 'true';
} catch (e) {
return false;
}
}
export function isSelectMediaMode() {
try {
const params = new URL(globalThis.location.href).searchParams;
const action = params.get('action');
return action === 'select_media';
} catch (e) {
return false;
}
}
export function inSelectMediaEmbedMode() {
return inEmbeddedApp() && isSelectMediaMode();
}
// When MediaCMS is embedded inside a host platform (e.g. an LMS), the host passes a
// `parent_media_base` URL via LTI custom params so that media title links in the embed
// player navigate the parent frame to the host's own media viewer (e.g. Moodle My Media)
// instead of opening a bare MediaCMS URL. The VideoViewer appends `?token=<friendly_token>`
// and uses `target="_parent"` to perform the navigation.
export function getParentMediaBase(): string | null {
try {
const params = new URL(globalThis.location.href).searchParams;
const mode = params.get('mode');
const base = params.get('parent_media_base');
if (mode === 'standard') {
sessionStorage.removeItem('parent_media_base');
return null;
}
if (base) {
sessionStorage.setItem('parent_media_base', base);
return base;
}
return sessionStorage.getItem('parent_media_base');
} catch (e) {
return null;
}
}
export function getLtiContextId(): string | null {
try {
const params = new URL(globalThis.location.href).searchParams;
const contextId = params.get('lti_context_id');
if (contextId) {
sessionStorage.setItem('lti_context_id', contextId);
return contextId;
}
return sessionStorage.getItem('lti_context_id');
} catch (e) {
return null;
}
}
@@ -14,4 +14,4 @@ export * from './quickSort';
export * from './requests';
export { translateString } from './translate';
export { replaceString } from './replacementStrings';
export * from './embeddedApp';
export { getParentMediaBase, inEmbeddedApp, inSelectMediaEmbedMode, isSelectMediaMode, isShareMediaDisabled } from './embeddedApp';
@@ -1,5 +1,5 @@
// check templates/config/installation/translations.html for more
export function translateString(str) {
return window.TRANSLATION?.[str] ?? str;
return window.TRANSLATION?.[str] || str;
}
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { translateString } from '../helpers';
import { inEmbeddedApp } from '../helpers/embeddedApp';
/**
* Custom hook for managing bulk actions on media items
@@ -22,6 +23,20 @@ export function useBulkActions() {
const [showPublishStateModal, setShowPublishStateModal] = useState(false);
const [showCategoryModal, setShowCategoryModal] = useState(false);
const [showTagModal, setShowTagModal] = useState(false);
const [showCourseCleanupModal, setShowCourseCleanupModal] = useState(false);
const [hasContributorCourses, setHasContributorCourses] = useState(false);
useEffect(() => {
if (!inEmbeddedApp()) return;
fetch('/api/v1/categories/contributor?lms_courses_only=true')
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!data) return;
const courses = data.results || data;
setHasContributorCourses(Array.isArray(courses) && courses.length > 0);
})
.catch(() => {});
}, []);
// Get CSRF token from cookies
const getCsrfToken = () => {
@@ -95,6 +110,11 @@ export function useBulkActions() {
const handleBulkAction = (action) => {
const selectedCount = selectedMedia.size;
if (action === 'course-cleanup') {
setShowCourseCleanupModal(true);
return;
}
if (selectedCount === 0) {
return;
}
@@ -111,6 +131,10 @@ export function useBulkActions() {
setShowConfirmModal(true);
setPendingAction(action);
setConfirmMessage(translateString('You are going to disable comments to') + ` ${selectedCount} ` + translateString('media, are you sure?'));
} else if (action === 'delete-comments') {
setShowConfirmModal(true);
setPendingAction(action);
setConfirmMessage(translateString('You are going to delete all comments from') + ` ${selectedCount} ` + translateString('media, are you sure?'));
} else if (action === 'enable-download') {
setShowConfirmModal(true);
setPendingAction(action);
@@ -165,6 +189,8 @@ export function useBulkActions() {
executeEnableComments();
} else if (action === 'disable-comments') {
executeDisableComments();
} else if (action === 'delete-comments') {
executeDeleteComments();
} else if (action === 'enable-download') {
executeEnableDownload();
} else if (action === 'disable-download') {
@@ -271,6 +297,37 @@ export function useBulkActions() {
});
};
// Execute delete comments
const executeDeleteComments = () => {
const selectedIds = Array.from(selectedMedia);
fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify({
action: 'delete_comments',
media_ids: selectedIds,
}),
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to delete comments');
}
return response.json();
})
.then((data) => {
showNotificationMessage(translateString('Successfully deleted comments'));
clearSelection();
})
.catch((error) => {
showNotificationMessage(translateString('Failed to delete comments.'), 'error');
clearSelection();
});
};
// Execute enable download
const executeEnableDownload = () => {
const selectedIds = Array.from(selectedMedia);
@@ -463,6 +520,22 @@ export function useBulkActions() {
setShowTagModal(false);
};
// Course cleanup modal handlers
const handleCourseCleanupModalCancel = () => {
setShowCourseCleanupModal(false);
};
const handleCourseCleanupModalSuccess = (message) => {
showNotificationMessage(message);
clearSelectionAndRefresh();
setShowCourseCleanupModal(false);
};
const handleCourseCleanupModalError = (message) => {
showNotificationMessage(message, 'error');
setShowCourseCleanupModal(false);
};
return {
// State
selectedMedia,
@@ -480,6 +553,8 @@ export function useBulkActions() {
showPublishStateModal,
showCategoryModal,
showTagModal,
showCourseCleanupModal,
hasContributorCourses,
// Handlers
handleMediaSelection,
@@ -507,6 +582,9 @@ export function useBulkActions() {
handleTagModalCancel,
handleTagModalSuccess,
handleTagModalError,
handleCourseCleanupModalCancel,
handleCourseCleanupModalSuccess,
handleCourseCleanupModalError,
// Utility
getCsrfToken,
@@ -195,13 +195,18 @@ class MediaPageStore extends EventEmitter {
this.emit('loaded_media_data');
}
this.loadPlaylists();
if (MediaCMS.features.media.actions.comment_mention === true) {
this.loadUsers();
}
// Skip loading playlists and comments when in embed mode (to reduce API calls)
const isEmbedMode = window.location.pathname.startsWith('/embed');
if (this.mediacms_config.member.can.readComment) {
this.loadComments();
if (!isEmbedMode) {
this.loadPlaylists();
if (MediaCMS.features.media.actions.comment_mention === true) {
this.loadUsers();
}
if (this.mediacms_config.member.can.readComment) {
this.loadComments();
}
}
}
@@ -882,7 +887,7 @@ class MediaPageStore extends EventEmitter {
submitCommentResponse(response) {
if (response && 201 === response.status && response.data && Object.keys(response.data)) {
MediaPageStoreData[this.id].comments.push(response.data);
MediaPageStoreData[this.id].comments.unshift(response.data);
this.emit('comment_submit', response.data.uid);
}
setTimeout(