Files
mediacms/frontend/src/static/js/components/BulkActionCategoryModal.tsx
T
2026-05-11 12:47:09 +03:00

299 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import './BulkActionCategoryModal.scss';
import { translateString } from '../utils/helpers/';
import { inEmbeddedApp } from '../utils/helpers/embeddedApp';
interface Category {
title: string;
uid: string;
}
interface BulkActionCategoryModalProps {
isOpen: boolean;
selectedMediaIds: string[];
onCancel: () => void;
onSuccess: (message: string) => void;
onError: (message: string) => void;
csrfToken: string;
}
export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = ({
isOpen,
selectedMediaIds,
onCancel,
onSuccess,
onError,
csrfToken,
}) => {
const isLmsMode = inEmbeddedApp();
const [existingCategories, setExistingCategories] = useState<Category[]>([]);
const [allCategories, setAllCategories] = useState<Category[]>([]);
const [categoriesToAdd, setCategoriesToAdd] = useState<Category[]>([]);
const [categoriesToRemove, setCategoriesToRemove] = useState<Category[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => {
if (isOpen && selectedMediaIds.length > 0) {
fetchData();
} else {
// Reset state when modal closes
setExistingCategories([]);
setAllCategories([]);
setCategoriesToAdd([]);
setCategoriesToRemove([]);
}
}, [isOpen, selectedMediaIds]);
const fetchData = async () => {
setIsLoading(true);
try {
// Fetch existing categories (intersection - categories all selected media belong to)
const existingResponse = 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 (!existingResponse.ok) {
throw new Error(translateString('Failed to fetch existing categories'));
}
const existingData = await existingResponse.json();
const existing = existingData.results || [];
// 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(isLmsMode ? translateString('Failed to fetch courses') : translateString('Failed to fetch all categories'));
}
const allData = await allResponse.json();
const all = allData.results || allData;
// 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(isLmsMode ? translateString('Failed to load courses') : translateString('Failed to load categories'));
} finally {
setIsLoading(false);
}
};
const addCategoryToList = (category: Category) => {
if (!categoriesToAdd.some((c) => c.uid === category.uid)) {
setCategoriesToAdd([...categoriesToAdd, category]);
}
};
const removeCategoryFromAddList = (category: Category) => {
setCategoriesToAdd(categoriesToAdd.filter((c) => c.uid !== category.uid));
};
const markCategoryForRemoval = (category: Category) => {
if (!categoriesToRemove.some((c) => c.uid === category.uid)) {
setCategoriesToRemove([...categoriesToRemove, category]);
}
};
const unmarkCategoryForRemoval = (category: Category) => {
setCategoriesToRemove(categoriesToRemove.filter((c) => c.uid !== category.uid));
};
const handleProceed = async () => {
setIsProcessing(true);
try {
// First, add categories if any
if (categoriesToAdd.length > 0) {
const uidsToAdd = categoriesToAdd.map((c) => c.uid);
const addResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'add_to_category',
media_ids: selectedMediaIds,
category_uids: uidsToAdd,
}),
});
if (!addResponse.ok) {
throw new Error(isLmsMode ? translateString('Failed to add courses') : translateString('Failed to add categories'));
}
}
// Then, remove categories if any
if (categoriesToRemove.length > 0) {
const uidsToRemove = categoriesToRemove.map((c) => c.uid);
const removeResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'remove_from_category',
media_ids: selectedMediaIds,
category_uids: uidsToRemove,
}),
});
if (!removeResponse.ok) {
throw new Error(isLmsMode ? translateString('Failed to remove courses') : translateString('Failed to remove categories'));
}
}
onSuccess(isLmsMode ? translateString('Successfully updated courses') : translateString('Successfully updated categories'));
onCancel();
} catch (error) {
console.error('Error processing categories:', error);
onError(isLmsMode ? translateString('Failed to update courses. Please try again.') : translateString('Failed to update categories. Please try again.'));
} finally {
setIsProcessing(false);
}
};
// Get categories for left panel (all categories minus those already existing)
const getLeftPanelCategories = () => {
return allCategories.filter(
(cat) => !existingCategories.some((existing) => existing.uid === cat.uid)
);
};
// Get categories for right panel ("Add to" - existing + newly added)
const getRightPanelCategories = () => {
// Combine existing categories with newly added ones
const combined = [...existingCategories, ...categoriesToAdd];
return combined;
};
if (!isOpen) return null;
const leftPanelCategories = getLeftPanelCategories();
const rightPanelCategories = getRightPanelCategories();
return (
<div className="category-modal-overlay">
<div className="category-modal">
<div className="category-modal-header">
<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>
</div>
<div className="category-modal-content">
<div className="category-panel">
<h3>{isLmsMode ? translateString('Courses') : translateString('Categories')}</h3>
{isLoading ? (
<div className="loading-message">{isLmsMode ? translateString('Loading courses...') : translateString('Loading categories...')}</div>
) : (
<div className="category-list scrollable">
{leftPanelCategories.length === 0 ? (
<div className="empty-message">{isLmsMode ? translateString('All courses already added') : translateString('All categories already added')}</div>
) : (
leftPanelCategories.map((category) => (
<div
key={category.uid}
className="category-item clickable"
onClick={() => addCategoryToList(category)}
>
<span>{category.title}</span>
<button className="add-btn">+</button>
</div>
))
)}
</div>
)}
</div>
<div className="category-panel">
<h3>
{translateString('Add to')}
{selectedMediaIds.length > 1 && (
<span className="info-tooltip" title={translateString('The intersection of categories in the selected media is shown')}>
?
</span>
)}
</h3>
{isLoading ? (
<div className="loading-message">{isLmsMode ? translateString('Loading courses...') : translateString('Loading categories...')}</div>
) : (
<div className="category-list scrollable">
{rightPanelCategories.length === 0 ? (
<div className="empty-message">{isLmsMode ? translateString('No courses') : translateString('No categories')}</div>
) : (
rightPanelCategories.map((category) => {
const isExisting = existingCategories.some((c) => c.uid === category.uid);
const isMarkedForRemoval = categoriesToRemove.some((c) => c.uid === category.uid);
return (
<div key={category.uid} className={`category-item ${isMarkedForRemoval ? 'marked-for-removal' : ''}`}>
<span>{category.title}</span>
<button
className="remove-btn"
onClick={() => {
if (isExisting) {
// This is an existing category - mark/unmark for removal
isMarkedForRemoval ? unmarkCategoryForRemoval(category) : markCategoryForRemoval(category);
} else {
// This is a newly added category - remove from add list
removeCategoryFromAddList(category);
}
}}
title={isMarkedForRemoval ? translateString('Undo removal') : isExisting ? (isLmsMode ? translateString('Remove course') : translateString('Remove category')) : translateString('Remove from list')}
>
{isMarkedForRemoval ? '↺' : '×'}
</button>
</div>
);
})
)}
</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 || (categoriesToAdd.length === 0 && categoriesToRemove.length === 0)}
>
{isProcessing ? translateString('Processing...') : translateString('Proceed')}
</button>
</div>
</div>
</div>
);
};