CampFireCritics/src/pages/AdminSeasonsPage.jsx
2025-05-21 11:05:20 +03:00

568 lines
24 KiB
JavaScript
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, 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;