mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-06-07 09:24:20 -04:00
feat: LTI support and Moodle plugin
This commit is contained in:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user