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,
};