568 lines
24 KiB
JavaScript
568 lines
24 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import { useAuth } from '../../contexts/AuthContext';
|
||
import {
|
||
getMediaById, // To get the parent media title
|
||
getSeasonsByMediaId,
|
||
createSeason,
|
||
updateSeason,
|
||
deleteSeason,
|
||
getFileUrl, // To display season posters
|
||
uploadFile, // To upload season posters
|
||
deleteFile // To delete season posters
|
||
} from '../../services/pocketbaseService';
|
||
import Modal from '../../components/common/Modal';
|
||
import DatePicker from 'react-datepicker'; // Assuming you have react-datepicker installed
|
||
import "react-datepicker/dist/react-datepicker.css"; // Import styles
|
||
import { FaEdit, FaTrashAlt, FaPlus } from 'react-icons/fa'; // Import icons
|
||
|
||
|
||
// Season Form Component (can be reused for Add and Edit)
|
||
const SeasonForm = ({ season, mediaId, onSubmit, onCancel, isSubmitting, error }) => {
|
||
const { user } = useAuth(); // Get current user for created_by
|
||
const [formData, setFormData] = useState({
|
||
season_number: '',
|
||
title: '',
|
||
overview: '',
|
||
release_date: null,
|
||
// poster: null, // File object for new upload - Handled by separate state
|
||
is_published: false,
|
||
// media_id and created_by will be added in onSubmit handler
|
||
});
|
||
const [currentPosterUrl, setCurrentPosterUrl] = useState(null); // To display existing poster
|
||
const [posterFile, setPosterFile] = useState(null); // State for the selected file input
|
||
const [deleteExistingPoster, setDeleteExistingPoster] = useState(false); // State to track if existing poster should be deleted
|
||
|
||
|
||
useEffect(() => {
|
||
console.log('SeasonForm useEffect: season changed', season);
|
||
if (season) {
|
||
// Pre-fill form for editing
|
||
setFormData({
|
||
season_number: season.season_number || '',
|
||
title: season.title || '',
|
||
overview: season.overview || '',
|
||
release_date: season.release_date ? new Date(season.release_date) : null,
|
||
// poster: null, // Clear file input state - Handled by separate state
|
||
is_published: season.is_published ?? false,
|
||
});
|
||
// Set current poster URL if exists
|
||
setCurrentPosterUrl(season.poster ? getFileUrl(season, 'poster') : null);
|
||
setPosterFile(null); // Clear file input
|
||
setDeleteExistingPoster(false); // Reset delete flag
|
||
} else {
|
||
// Reset form for adding
|
||
setFormData({
|
||
season_number: '',
|
||
title: '',
|
||
overview: '',
|
||
release_date: null,
|
||
// poster: null,
|
||
is_published: false,
|
||
});
|
||
setCurrentPosterUrl(null);
|
||
setPosterFile(null);
|
||
setDeleteExistingPoster(false);
|
||
}
|
||
}, [season]);
|
||
|
||
const handleChange = (e) => {
|
||
const { name, value, type, checked } = e.target;
|
||
setFormData(prev => ({
|
||
...prev,
|
||
[name]: type === 'checkbox' ? checked : value
|
||
}));
|
||
};
|
||
|
||
const handleDateChange = (date) => {
|
||
setFormData(prev => ({
|
||
...prev,
|
||
release_date: date
|
||
}));
|
||
};
|
||
|
||
const handleFileChange = (e) => {
|
||
const file = e.target.files?.[0] || null;
|
||
setPosterFile(file); // Store the file object
|
||
// If a new file is selected, don't delete the existing one
|
||
if (file) {
|
||
setDeleteExistingPoster(false);
|
||
}
|
||
};
|
||
|
||
const handleRemoveExistingPoster = () => {
|
||
setCurrentPosterUrl(null); // Hide the current poster preview
|
||
setDeleteExistingPoster(true); // Mark for deletion on submit
|
||
setPosterFile(null); // Ensure no new file is selected
|
||
};
|
||
|
||
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault();
|
||
|
||
// Basic validation
|
||
if (!formData.season_number || isNaN(parseInt(formData.season_number)) || parseInt(formData.season_number) <= 0) {
|
||
alert('Номер сезона обязателен и должен быть положительным числом.');
|
||
return;
|
||
}
|
||
|
||
// Create FormData for PocketBase
|
||
const dataToSend = new FormData();
|
||
dataToSend.append('media_id', mediaId); // Link to parent media
|
||
dataToSend.append('season_number', parseInt(formData.season_number)); // Ensure number type
|
||
dataToSend.append('title', formData.title || '');
|
||
dataToSend.append('overview', formData.overview || '');
|
||
if (formData.release_date) {
|
||
// PocketBase expects ISO string for datetime/date fields
|
||
dataToSend.append('release_date', formData.release_date.toISOString());
|
||
} else {
|
||
// Append empty string or null if date is optional and not set
|
||
// PocketBase handles empty string for optional date fields
|
||
dataToSend.append('release_date', '');
|
||
}
|
||
dataToSend.append('is_published', formData.is_published);
|
||
dataToSend.append('created_by', user.id); // Set creator to current user
|
||
|
||
// Handle poster file upload or deletion
|
||
if (posterFile) {
|
||
// Append the new file
|
||
dataToSend.append('poster', posterFile);
|
||
} else if (deleteExistingPoster) {
|
||
// If no new file and delete flag is true, signal deletion
|
||
// For FormData, set the file field to an empty string or empty FileList
|
||
// Empty string is simpler for single file fields
|
||
dataToSend.append('poster', ''); // Signal deletion
|
||
}
|
||
// If no new file and not deleting, do not append 'poster' field at all
|
||
// This prevents PocketBase from trying to update it if it's unchanged
|
||
|
||
|
||
onSubmit(dataToSend); // Pass FormData to parent handler
|
||
};
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="space-y-6">
|
||
{error && (
|
||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg">
|
||
Ошибка: {error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Season Number */}
|
||
<div>
|
||
<label htmlFor="season_number" className="block mb-2 text-campfire-light text-sm font-medium">
|
||
Номер сезона <span className="text-red-500">*</span>
|
||
</label>
|
||
<input
|
||
type="number"
|
||
id="season_number"
|
||
name="season_number"
|
||
value={formData.season_number}
|
||
onChange={handleChange}
|
||
className="w-full p-3 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent transition-colors"
|
||
required
|
||
min="1"
|
||
/>
|
||
</div>
|
||
|
||
{/* Title */}
|
||
<div>
|
||
<label htmlFor="title" className="block mb-2 text-campfire-light text-sm font-medium">
|
||
Название сезона (опционально)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
id="title"
|
||
name="title"
|
||
value={formData.title}
|
||
onChange={handleChange}
|
||
className="w-full p-3 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent transition-colors"
|
||
/>
|
||
</div>
|
||
|
||
{/* Overview */}
|
||
<div>
|
||
<label htmlFor="overview" className="block mb-2 text-campfire-light text-sm font-medium">
|
||
Описание сезона (опционально)
|
||
</label>
|
||
<textarea
|
||
id="overview"
|
||
name="overview"
|
||
rows="4"
|
||
value={formData.overview}
|
||
onChange={handleChange}
|
||
className="w-full p-3 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent transition-colors"
|
||
/>
|
||
</div>
|
||
|
||
{/* Release Date */}
|
||
<div>
|
||
<label htmlFor="release_date" className="block mb-2 text-campfire-light text-sm font-medium">
|
||
Дата выхода (опционально)
|
||
</label>
|
||
<DatePicker
|
||
id="release_date"
|
||
selected={formData.release_date}
|
||
onChange={handleDateChange}
|
||
dateFormat="dd.MM.yyyy"
|
||
className="w-full p-3 bg-campfire-dark border border-campfire-ash/30 rounded-md text-campfire-light focus:ring-2 focus:ring-campfire-amber focus:border-transparent transition-colors"
|
||
placeholderText="Выберите дату"
|
||
isClearable
|
||
/>
|
||
</div>
|
||
|
||
{/* Poster */}
|
||
<div>
|
||
<label htmlFor="poster" className="block mb-2 text-campfire-light text-sm font-medium">
|
||
Постер сезона (опционально)
|
||
</label>
|
||
{currentPosterUrl && !deleteExistingPoster && (
|
||
<div className="mb-4">
|
||
<p className="text-sm text-campfire-ash mb-2">Текущий постер:</p>
|
||
<img src={currentPosterUrl} alt="Current Poster" className="w-24 h-auto rounded-md mb-2" />
|
||
<button
|
||
type="button"
|
||
onClick={handleRemoveExistingPoster}
|
||
className="text-red-500 hover:underline text-sm"
|
||
>
|
||
Удалить текущий постер
|
||
</button>
|
||
</div>
|
||
)}
|
||
<input
|
||
type="file"
|
||
id="poster"
|
||
name="poster"
|
||
onChange={handleFileChange}
|
||
className="w-full text-campfire-light text-sm
|
||
file:mr-4 file:py-2 file:px-4
|
||
file:rounded-md file:border-0
|
||
file:text-sm file:font-semibold
|
||
file:bg-campfire-amber file:text-campfire-dark
|
||
hover:file:bg-campfire-amber/80 cursor-pointer"
|
||
/>
|
||
{/* Hidden input is not needed with FormData append logic */}
|
||
{/* {deleteExistingPoster && <input type="hidden" name="poster" value="" />} */}
|
||
</div>
|
||
|
||
{/* Is Published */}
|
||
<div className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
id="is_published"
|
||
name="is_published"
|
||
checked={formData.is_published}
|
||
onChange={handleChange}
|
||
className="w-4 h-4 text-campfire-amber bg-campfire-dark border-campfire-ash rounded focus:ring-campfire-amber focus:ring-2 cursor-pointer"
|
||
/>
|
||
<label htmlFor="is_published" className="ml-2 text-campfire-light text-sm font-medium cursor-pointer">
|
||
Опубликовать сезон
|
||
</label>
|
||
</div>
|
||
|
||
{/* Form Actions */}
|
||
<div className="flex justify-end space-x-4 mt-6">
|
||
<button
|
||
type="button"
|
||
onClick={onCancel}
|
||
className="btn-secondary"
|
||
disabled={isSubmitting}
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||
disabled={isSubmitting}
|
||
>
|
||
{isSubmitting ? (season ? 'Сохранение...' : 'Создание...') : (season ? 'Сохранить сезон' : 'Создать сезон')}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
);
|
||
};
|
||
|
||
|
||
const AdminSeasonsPage = () => {
|
||
const { mediaId } = useParams(); // Get mediaId from URL
|
||
const navigate = useNavigate();
|
||
const { user, userProfile, loading: authLoading } = useAuth();
|
||
|
||
const [media, setMedia] = useState(null); // To store parent media details
|
||
const [seasons, setSeasons] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
|
||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||
const [seasonToEdit, setSeasonToEdit] = useState(null);
|
||
const [formError, setFormError] = useState(null); // Error for the form modal
|
||
const [isSubmittingForm, setIsSubmittingForm] = useState(false); // Submitting state for form
|
||
|
||
|
||
// Check if the current user is admin
|
||
const isAdmin = userProfile?.role === 'admin';
|
||
|
||
// Use useCallback to memoize loadData
|
||
const loadData = useCallback(async () => {
|
||
if (!mediaId) {
|
||
setError('ID медиа не указан.');
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
// Fetch parent media details
|
||
const mediaData = await getMediaById(mediaId);
|
||
if (!mediaData) {
|
||
setError('Родительское медиа не найдено.');
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
setMedia(mediaData);
|
||
|
||
// Fetch seasons for this media
|
||
const seasonsData = await getSeasonsByMediaId(mediaId);
|
||
// Sort seasons by season_number
|
||
seasonsData.sort((a, b) => a.season_number - b.season_number);
|
||
setSeasons(seasonsData || []);
|
||
|
||
} catch (err) {
|
||
console.error('Error loading seasons data:', err);
|
||
setError('Не удалось загрузить данные сезонов.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [mediaId]); // Depend on mediaId
|
||
|
||
|
||
useEffect(() => {
|
||
console.log('AdminSeasonsPage mounted, mediaId:', mediaId, 'user:', user);
|
||
|
||
// Wait for auth to finish loading before checking user/profile
|
||
if (authLoading) {
|
||
console.log('AdminSeasonsPage: Auth loading...');
|
||
return;
|
||
}
|
||
|
||
// Redirect if not admin (AdminRoute should handle this, but double-check)
|
||
if (!user || !isAdmin) {
|
||
console.warn('AdminSeasonsPage: User is not admin, redirecting.');
|
||
navigate('/admin'); // Redirect to admin dashboard or login
|
||
return;
|
||
}
|
||
|
||
// Load data if user is admin and mediaId is available
|
||
if (user && isAdmin && mediaId) {
|
||
loadData();
|
||
}
|
||
|
||
}, [mediaId, user, userProfile, authLoading, navigate, isAdmin, loadData]); // Depend on auth states, navigate, loadData
|
||
|
||
|
||
const handleAddSeason = () => {
|
||
setSeasonToEdit(null); // Ensure we are adding, not editing
|
||
setFormError(null); // Clear previous form errors
|
||
setIsAddModalOpen(true);
|
||
};
|
||
|
||
const handleEditSeason = (season) => {
|
||
setSeasonToEdit(season); // Set season to edit
|
||
setFormError(null); // Clear previous form errors
|
||
setIsEditModalOpen(true);
|
||
};
|
||
|
||
const handleDeleteSeason = async (seasonId) => {
|
||
if (!window.confirm('Вы уверены, что хотите удалить этот сезон? Это также удалит все связанные с ним рецензии!')) {
|
||
return;
|
||
}
|
||
try {
|
||
setLoading(true); // Show main loading indicator
|
||
setError(null); // Clear main error
|
||
await deleteSeason(seasonId);
|
||
loadData(); // Reload the list after deletion
|
||
} catch (err) {
|
||
console.error('Error deleting season:', err);
|
||
setError('Не удалось удалить сезон.');
|
||
setLoading(false); // Hide loading on error
|
||
}
|
||
};
|
||
|
||
const handleFormSubmit = async (formData) => {
|
||
setIsSubmittingForm(true);
|
||
setFormError(null); // Clear previous form errors
|
||
|
||
try {
|
||
if (seasonToEdit) {
|
||
// Update existing season
|
||
await updateSeason(seasonToEdit.id, formData);
|
||
console.log('Season updated successfully.');
|
||
} else {
|
||
// Create new season
|
||
await createSeason(formData);
|
||
console.log('Season created successfully.');
|
||
}
|
||
// Close modal and reload data
|
||
setIsAddModalOpen(false);
|
||
setIsEditModalOpen(false);
|
||
setSeasonToEdit(null);
|
||
loadData(); // Reload the list after add/edit
|
||
} catch (err) {
|
||
console.error('Error submitting season form:', err);
|
||
// Set form-specific error
|
||
setFormError(err.message || 'Произошла ошибка при сохранении сезона.');
|
||
} finally {
|
||
setIsSubmittingForm(false);
|
||
}
|
||
};
|
||
|
||
const handleFormCancel = () => {
|
||
setIsAddModalOpen(false);
|
||
setIsEditModalOpen(false);
|
||
setSeasonToEdit(null);
|
||
setFormError(null); // Clear form error on cancel
|
||
};
|
||
|
||
|
||
if (authLoading || loading) {
|
||
return <div className="flex justify-center items-center h-screen text-campfire-light">Загрузка...</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>
|
||
);
|
||
}
|
||
|
||
if (!media) {
|
||
return (
|
||
<div className="min-h-screen bg-campfire-dark pt-20 flex justify-center items-center">
|
||
<div className="text-campfire-ash text-lg">Родительское медиа не найдено.</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
return (
|
||
<div> {/* Removed container-custom and pt-20 */}
|
||
<div className="flex justify-between items-center mb-8">
|
||
<h2 className="text-2xl font-bold text-campfire-light">
|
||
Сезоны для "{media.title}"
|
||
</h2>
|
||
<button
|
||
onClick={handleAddSeason}
|
||
className="btn-primary flex items-center space-x-2"
|
||
>
|
||
<FaPlus size={18} />
|
||
<span>Добавить сезон</span>
|
||
</button>
|
||
</div>
|
||
|
||
{seasons.length === 0 ? (
|
||
<div className="text-center py-8 text-campfire-light">
|
||
Сезоны не найдены. Добавьте первый сезон!
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{seasons.map((season) => (
|
||
<div
|
||
key={season.id}
|
||
className="bg-campfire-charcoal rounded-lg overflow-hidden border border-campfire-ash/20 flex flex-col" // Use flex-col
|
||
>
|
||
{season.poster && (
|
||
<img
|
||
src={getFileUrl(season, 'poster')}
|
||
alt={`Постер сезона ${season.season_number}`}
|
||
className="w-full h-48 object-cover"
|
||
/>
|
||
)}
|
||
<div className="p-4 flex-grow flex flex-col"> {/* Use flex-grow and flex-col */}
|
||
<h3 className="text-xl font-semibold text-campfire-amber mb-2">
|
||
Сезон {season.season_number} {season.title ? `- ${season.title}` : ''}
|
||
</h3>
|
||
{season.overview && (
|
||
<p className="text-campfire-light text-sm mb-2 line-clamp-3 flex-grow"> {/* Use flex-grow */}
|
||
{season.overview}
|
||
</p>
|
||
)}
|
||
{season.release_date && (
|
||
<p className="text-campfire-ash text-sm mb-4">
|
||
Дата выхода: {new Date(season.release_date).toLocaleDateString()}
|
||
</p>
|
||
)}
|
||
{/* Display season rating and review count */}
|
||
<div className="flex items-center text-campfire-ash text-sm mt-2">
|
||
<span className="text-campfire-amber mr-2 font-bold">
|
||
{season.average_rating !== null && season.average_rating !== undefined && !isNaN(parseFloat(season.average_rating)) ? parseFloat(season.average_rating).toFixed(1) : 'N/A'} / 10
|
||
</span>
|
||
<FaFire className="mr-2 text-base" />
|
||
<span>
|
||
{season.review_count !== null && season.review_count !== undefined && !isNaN(parseInt(season.review_count)) ? parseInt(season.review_count) : 0} рецензий
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-end space-x-4 mt-auto"> {/* Use mt-auto to push to bottom */}
|
||
<button
|
||
onClick={() => handleEditSeason(season)}
|
||
className="text-campfire-amber hover:text-campfire-light flex items-center space-x-1"
|
||
title="Редактировать сезон"
|
||
>
|
||
<FaEdit size={18} />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteSeason(season.id)}
|
||
className="text-red-500 hover:text-red-400 flex items-center space-x-1"
|
||
title="Удалить сезон"
|
||
>
|
||
<FaTrashAlt size={18} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Add Season Modal */}
|
||
<Modal
|
||
isOpen={isAddModalOpen}
|
||
onClose={handleFormCancel}
|
||
title={`Добавить сезон для "${media?.title || ''}"`}
|
||
size="lg"
|
||
>
|
||
<SeasonForm
|
||
mediaId={mediaId}
|
||
onSubmit={handleFormSubmit}
|
||
onCancel={handleFormCancel}
|
||
isSubmitting={isSubmittingForm}
|
||
error={formError}
|
||
/>
|
||
</Modal>
|
||
|
||
{/* Edit Season Modal */}
|
||
<Modal
|
||
isOpen={isEditModalOpen}
|
||
onClose={handleFormCancel}
|
||
title={`Редактировать сезон ${seasonToEdit?.season_number || ''} для "${media?.title || ''}"`}
|
||
size="lg"
|
||
>
|
||
<SeasonForm
|
||
season={seasonToEdit} // Pass the season data for editing
|
||
mediaId={mediaId}
|
||
onSubmit={handleFormSubmit}
|
||
onCancel={handleFormCancel}
|
||
isSubmitting={isSubmittingForm}
|
||
error={formError}
|
||
/>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AdminSeasonsPage;
|