325 lines
14 KiB
JavaScript
325 lines
14 KiB
JavaScript
import React, { useState, useEffect, useMemo } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import {
|
||
listMedia,
|
||
deleteMedia,
|
||
getFileUrl, // Import getFileUrl
|
||
mediaTypes // Import mediaTypes
|
||
} from '../services/pocketbaseService';
|
||
import { useAuth } from '../contexts/AuthContext';
|
||
import Modal from '../components/Modal';
|
||
import MediaForm from '../components/admin/MediaForm'; // Import MediaForm
|
||
|
||
const AdminMediaPage = () => {
|
||
const [media, setMedia] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [currentMedia, setCurrentMedia] = useState(null); // For editing
|
||
const [page, setPage] = useState(1);
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const [filterType, setFilterType] = useState(''); // State for type filter
|
||
const [filterPublished, setFilterPublished] = useState(''); // State for published filter
|
||
const [sortField, setSortField] = useState('-created'); // State for sorting
|
||
|
||
const { userProfile } = useAuth();
|
||
const isAdminOrCritic = userProfile && (userProfile.role === 'admin' || userProfile.is_critic === true);
|
||
|
||
// Define sort options
|
||
const sortOptions = useMemo(() => [
|
||
{ value: '-created', label: 'Дата создания (новые)' },
|
||
{ value: 'created', label: 'Дата создания (старые)' },
|
||
{ value: '-title', label: 'Название (Я-А)' },
|
||
{ value: 'title', label: 'Название (А-Я)' },
|
||
{ value: '-average_rating', label: 'Рейтинг (убыв.)' },
|
||
{ value: 'average_rating', label: 'Рейтинг (возр.)' },
|
||
{ value: '-review_count', label: 'Рецензии (убыв.)' },
|
||
{ value: 'review_count', label: 'Рецензии (возр.)' },
|
||
], []);
|
||
|
||
|
||
const fetchMedia = async (currentPage, type, published, sort) => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
// Pass filter and sort parameters to listMedia
|
||
const publishedFilter = published === '' ? null : published === 'true'; // Convert string to boolean or null
|
||
const { data, totalPages } = await listMedia(type || null, currentPage, 20, userProfile, false, publishedFilter, sort);
|
||
setMedia(data);
|
||
setTotalPages(totalPages);
|
||
} catch (err) {
|
||
console.error("Error fetching media:", err);
|
||
setError("Не удалось загрузить список контента.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
// Fetch media when page, filter, or sort changes
|
||
fetchMedia(page, filterType, filterPublished, sortField);
|
||
}, [page, filterType, filterPublished, sortField, userProfile]); // Add userProfile as dependency
|
||
|
||
|
||
const handleDelete = async (id) => {
|
||
if (window.confirm("Вы уверены, что хотите удалить этот контент?")) {
|
||
try {
|
||
await deleteMedia(id);
|
||
// Refresh the list after deletion
|
||
fetchMedia(page, filterType, filterPublished, sortField);
|
||
} catch (err) {
|
||
console.error("Error deleting media:", err);
|
||
setError("Не удалось удалить контент.");
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleCreateClick = () => {
|
||
setCurrentMedia(null); // Clear currentMedia for creation
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleEditClick = (mediaItem) => {
|
||
setCurrentMedia(mediaItem); // Set mediaItem for editing
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleModalClose = () => {
|
||
setIsModalOpen(false);
|
||
setCurrentMedia(null); // Clear currentMedia
|
||
// Refresh the list after modal close (assuming create/edit happened)
|
||
fetchMedia(page, filterType, filterPublished, sortField);
|
||
};
|
||
|
||
if (!isAdminOrCritic) {
|
||
return (
|
||
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center text-campfire-light">
|
||
<p>У вас нет прав для просмотра этой страницы.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-campfire-amber"></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
|
||
<div className="bg-status-error/20 text-status-error p-4 rounded-lg text-center">
|
||
{error}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-campfire-dark pt-20">
|
||
<div className="container-custom py-12">
|
||
<div className="flex justify-between items-center mb-8">
|
||
<h1 className="text-3xl font-bold text-campfire-light">
|
||
Управление <span className="font-semibold text-campfire-amber">контентом</span>
|
||
</h1>
|
||
<button onClick={handleCreateClick} className="btn-primary">
|
||
Добавить контент
|
||
</button>
|
||
</div>
|
||
|
||
{/* Filter and Sort Controls */}
|
||
<div className="mb-8 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div>
|
||
<label htmlFor="filterType" className="block text-sm font-medium text-campfire-light mb-1">
|
||
Фильтр по типу
|
||
</label>
|
||
<select
|
||
id="filterType"
|
||
value={filterType}
|
||
onChange={(e) => { setFilterType(e.target.value); setPage(1); }} // Reset page on filter change
|
||
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
|
||
>
|
||
<option value="">Все типы</option>
|
||
{Object.entries(mediaTypes).map(([key, value]) => (
|
||
<option key={key} value={key}>{value.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="filterPublished" className="block text-sm font-medium text-campfire-light mb-1">
|
||
Фильтр по статусу публикации
|
||
</label>
|
||
<select
|
||
id="filterPublished"
|
||
value={filterPublished}
|
||
onChange={(e) => { setFilterPublished(e.target.value); setPage(1); }} // Reset page on filter change
|
||
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
|
||
>
|
||
<option value="">Все статусы</option>
|
||
<option value="true">Опубликовано</option>
|
||
<option value="false">Не опубликовано</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="sortField" className="block text-sm font-medium text-campfire-light mb-1">
|
||
Сортировка
|
||
</label>
|
||
<select
|
||
id="sortField"
|
||
value={sortField}
|
||
onChange={(e) => { setSortField(e.target.value); setPage(1); }} // Reset page on sort change
|
||
className="input w-full px-3 py-2 bg-campfire-dark text-campfire-light border border-campfire-ash/30 rounded-md focus:outline-none focus:ring-campfire-amber focus:border-campfire-amber transition-colors"
|
||
>
|
||
{sortOptions.map(option => (
|
||
<option key={option.value} value={option.value}>{option.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
{media.length > 0 ? (
|
||
<div className="overflow-x-auto bg-campfire-charcoal rounded-lg shadow-md border border-campfire-ash/20">
|
||
<table className="min-w-full divide-y divide-campfire-ash/20">
|
||
<thead className="bg-campfire-dark">
|
||
<tr>
|
||
<th
|
||
scope="col"
|
||
className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider"
|
||
>
|
||
Постер
|
||
</th>
|
||
<th
|
||
scope="col"
|
||
className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider"
|
||
>
|
||
Название
|
||
</th>
|
||
<th
|
||
scope="col"
|
||
className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider"
|
||
>
|
||
Тип
|
||
</th>
|
||
<th
|
||
scope="col"
|
||
className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider"
|
||
>
|
||
Опубликовано
|
||
</th>
|
||
<th
|
||
scope="col"
|
||
className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider"
|
||
>
|
||
Популярное
|
||
</th>
|
||
<th
|
||
scope="col"
|
||
className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider"
|
||
>
|
||
Рейтинг
|
||
</th>
|
||
<th
|
||
scope="col"
|
||
className="px-6 py-3 text-left text-xs font-medium text-campfire-ash uppercase tracking-wider"
|
||
>
|
||
Рецензии
|
||
</th>
|
||
<th scope="col" className="relative px-6 py-3">
|
||
<span className="sr-only">Действия</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-campfire-charcoal divide-y divide-campfire-ash/20 text-campfire-light">
|
||
{media.map((item) => (
|
||
<tr key={item.id}>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<img
|
||
src={getFileUrl(item, 'poster', { thumb: '50x50' })} // Use getFileUrl
|
||
alt={item.title}
|
||
className="h-10 w-10 rounded object-cover"
|
||
/>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-campfire-light">
|
||
<Link to={`/media/${item.path}`} className="hover:text-campfire-amber transition-colors">
|
||
{item.title}
|
||
</Link>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-ash">
|
||
{mediaTypes[item.type]?.label || item.type}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-ash">
|
||
{item.is_published ? 'Да' : 'Нет'}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-ash">
|
||
{item.is_popular ? 'Да' : 'Нет'}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-ash">
|
||
{item.average_rating !== null && item.average_rating !== undefined ? item.average_rating.toFixed(1) : 'N/A'}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-campfire-ash">
|
||
{item.review_count !== null && item.review_count !== undefined ? item.review_count : 0}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||
<button
|
||
onClick={() => handleEditClick(item)}
|
||
className="text-campfire-amber hover:text-campfire-ember mr-4 transition-colors"
|
||
>
|
||
Редактировать
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(item.id)}
|
||
className="text-status-error hover:text-red-700 transition-colors"
|
||
>
|
||
Удалить
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-12 text-campfire-ash text-lg">
|
||
Нет доступного контента.
|
||
</div>
|
||
)}
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="flex justify-center mt-8 space-x-4">
|
||
<button
|
||
onClick={() => setPage(prev => Math.max(prev - 1, 1))}
|
||
disabled={page === 1}
|
||
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Предыдущая
|
||
</button>
|
||
<span className="text-campfire-light text-lg font-medium">
|
||
Страница {page} из {totalPages}
|
||
</span>
|
||
<button
|
||
onClick={() => setPage(prev => Math.min(prev + 1, totalPages))}
|
||
disabled={page === totalPages}
|
||
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Следующая
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
|
||
{/* Media Create/Edit Modal */}
|
||
<Modal isOpen={isModalOpen} onClose={handleModalClose}>
|
||
<MediaForm media={currentMedia} onSuccess={handleModalClose} />
|
||
</Modal>
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AdminMediaPage;
|