514 lines
24 KiB
JavaScript
514 lines
24 KiB
JavaScript
import { useState, useEffect } from 'react';
|
||
import { FaFire, FaEdit, FaTrashAlt } from 'react-icons/fa'; // Changed FaStar to FaFire
|
||
import RatingChart from './RatingChart';
|
||
import FlameRatingInput from './FlameRatingInput'; // Import the new component
|
||
import ReactQuill from 'react-quill'; // Import ReactQuill
|
||
import 'react-quill/dist/quill.snow.css'; // Import Quill styles (snow theme)
|
||
|
||
|
||
// Default characteristics if none are provided (fallback)
|
||
const defaultCharacteristics = {
|
||
overall: 'Общая оценка' // Ensure a default 'overall' characteristic
|
||
};
|
||
|
||
// Mapping for watched/completed status values
|
||
const watchedStatusLabels = {
|
||
not_watched: 'Не просмотрено',
|
||
watched: 'Просмотрено',
|
||
};
|
||
|
||
const completedStatusLabels = {
|
||
not_completed: 'Не пройдено',
|
||
completed: 'Пройдено',
|
||
};
|
||
|
||
|
||
// ReviewForm now expects characteristics prop to be the media's characteristics { [key]: label }
|
||
// Also expects mediaType, progressType, seasons, and selectedSeasonId
|
||
function ReviewForm({ mediaId, seasonId, mediaType, progressType, onSubmit, onEdit, onDelete, characteristics = defaultCharacteristics, existingReview, seasons = [], selectedSeasonId }) {
|
||
// Initial state for ratings, now storing just the number { [key]: number }
|
||
// Initialize with a default value (e.g., 5) for each characteristic provided
|
||
const initialRatings = Object.keys(characteristics).reduce((acc, key) => {
|
||
acc[key] = 5; // Default rating of 5
|
||
return acc;
|
||
}, {});
|
||
|
||
const [ratings, setRatings] = useState(initialRatings);
|
||
// Use empty string for Quill content state initially
|
||
const [content, setContent] = useState('');
|
||
const [hasSpoilers, setHasSpoilers] = useState(false);
|
||
// Use 'progress' state instead of 'status'
|
||
const [progress, setProgress] = useState(''); // State for progress (text field)
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [isEditing, setIsEditing] = useState(false);
|
||
|
||
// New state for selected season in the form (only relevant if media supports seasons)
|
||
// Initialize with the selectedSeasonId passed from the parent
|
||
const [formSeasonId, setFormSeasonId] = useState(selectedSeasonId);
|
||
|
||
|
||
// Determine the correct progress options/label based *only* on progressType
|
||
const getProgressOptions = () => {
|
||
if (progressType === 'watched') {
|
||
return watchedStatusLabels;
|
||
} else if (progressType === 'completed') {
|
||
return completedStatusLabels;
|
||
}
|
||
// If progressType is 'hours' or unknown, use text input
|
||
return null;
|
||
};
|
||
|
||
const progressOptions = getProgressOptions();
|
||
const isProgressSelect = progressOptions !== null; // Determine if we should show a select/segmented control
|
||
|
||
// Determine if the media type supports seasons
|
||
const supportsSeasons = mediaType === 'tv' || mediaType === 'anime';
|
||
|
||
|
||
// Reset ratings, content, progress, and formSeasonId when characteristics, existingReview, progressType, or selectedSeasonId change
|
||
useEffect(() => {
|
||
console.log('ReviewForm useEffect: existingReview changed', existingReview); // LOG
|
||
console.log('ReviewForm useEffect: selectedSeasonId changed', selectedSeasonId); // LOG
|
||
// Ensure characteristics is an object before processing
|
||
const validCharacteristics = characteristics && typeof characteristics === 'object' ? characteristics : defaultCharacteristics;
|
||
|
||
if (existingReview) {
|
||
// If editing, pre-fill form with existing review data
|
||
// Expect existingReview.ratings to be { [key]: number }
|
||
const populatedRatings = Object.keys(validCharacteristics).reduce((acc, key) => {
|
||
// Use existing rating if it's a number, otherwise default to 5
|
||
acc[key] = typeof existingReview.ratings?.[key] === 'number' ? existingReview.ratings[key] : 5;
|
||
return acc;
|
||
}, {});
|
||
|
||
setRatings(populatedRatings);
|
||
// Set content from existing review (assuming it's HTML)
|
||
setContent(existingReview.content || '');
|
||
setHasSpoilers(existingReview.has_spoilers ?? false);
|
||
// Initialize progress from existing review
|
||
setProgress(existingReview.progress || '');
|
||
// Initialize formSeasonId from existing review's season_id
|
||
setFormSeasonId(existingReview.season_id || null); // Use null for overall review
|
||
console.log('ReviewForm useEffect: Setting isEditing to false (existing review)'); // LOG
|
||
setIsEditing(false); // Start in view mode
|
||
} else {
|
||
// If creating, reset form
|
||
const newInitialRatings = Object.keys(validCharacteristics).reduce((acc, key) => {
|
||
acc[key] = 5; // Default rating of 5
|
||
return acc;
|
||
}, {});
|
||
setRatings(newInitialRatings);
|
||
setContent(''); // Reset content to empty string for Quill
|
||
setHasSpoilers(false);
|
||
// Reset progress based on the type of input expected
|
||
if (isProgressSelect) {
|
||
// Set default for select based on options (e.g., 'completed' or 'not_watched')
|
||
// Default to the first option key, which should be the 'not_' status
|
||
setProgress(Object.keys(progressOptions)[0] || '');
|
||
} else {
|
||
setProgress(''); // Default to empty string for text input (hours)
|
||
}
|
||
// Reset formSeasonId to the currently selected season on the page
|
||
setFormSeasonId(selectedSeasonId);
|
||
console.log('ReviewForm useEffect: Resetting form, setting isEditing to false (no existing review)'); // LOG
|
||
setIsEditing(false);
|
||
}
|
||
}, [characteristics, existingReview, progressType, isProgressSelect, selectedSeasonId, seasons]); // Depend on selectedSeasonId and seasons too
|
||
|
||
|
||
// Add a log to see when isEditing state changes
|
||
useEffect(() => {
|
||
console.log('ReviewForm: isEditing state changed to', isEditing); // LOG
|
||
}, [isEditing]);
|
||
|
||
|
||
const handleRatingChange = (category, value) => {
|
||
const newRatings = {
|
||
...ratings,
|
||
[category]: value
|
||
};
|
||
|
||
// Рассчитываем overall_rating как среднее всех оценок
|
||
const validRatings = Object.values(newRatings).filter(rating => typeof rating === 'number');
|
||
const overallRating = validRatings.length > 0
|
||
? validRatings.reduce((sum, rating) => sum + rating, 0) / validRatings.length
|
||
: 0;
|
||
|
||
setRatings({
|
||
...newRatings,
|
||
overall: overallRating
|
||
});
|
||
};
|
||
|
||
const handleProgressChange = (value) => {
|
||
setProgress(value);
|
||
};
|
||
|
||
const handleFormSeasonChange = (e) => {
|
||
// Convert the value to null if it's the "Общее" option
|
||
const value = e.target.value === '' ? null : e.target.value;
|
||
setFormSeasonId(value);
|
||
};
|
||
|
||
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault();
|
||
setIsSubmitting(true);
|
||
|
||
try {
|
||
// Рассчитываем overall_rating перед отправкой
|
||
const validRatings = Object.entries(ratings)
|
||
.filter(([key, value]) => key !== 'overall' && typeof value === 'number');
|
||
const overallRating = validRatings.length > 0
|
||
? validRatings.reduce((sum, [_, rating]) => sum + rating, 0) / validRatings.length
|
||
: 0;
|
||
|
||
const reviewData = {
|
||
media_id: mediaId,
|
||
season_id: formSeasonId,
|
||
media_type: mediaType,
|
||
content,
|
||
ratings: {
|
||
...ratings,
|
||
overall: overallRating
|
||
},
|
||
has_spoilers: hasSpoilers,
|
||
progress: progress,
|
||
progress_type: progressType
|
||
};
|
||
|
||
if (existingReview && isEditing) {
|
||
await onEdit(existingReview.id, reviewData);
|
||
setIsEditing(false);
|
||
} else {
|
||
await onSubmit(reviewData);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error submitting review:', error);
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (window.confirm('Вы уверены, что хотите удалить эту рецензию?')) {
|
||
setIsSubmitting(true);
|
||
try {
|
||
await onDelete(existingReview.id, mediaId); // deleteReview only needs reviewId, mediaId is optional
|
||
// Form reset is handled by the useEffect when existingReview becomes null
|
||
} catch (error) {
|
||
console.error('Error deleting review:', error);
|
||
// Optionally set an error state
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Determine if the form is valid for submission
|
||
// Content must not be empty, ALL characteristics must have a valid rating (1-10), and progress must be filled/selected
|
||
const isFormValid = content.trim() !== '' && content !== '<p><br></p>' && // Check content validity
|
||
Object.keys(characteristics).length > 0 && // Ensure characteristics are loaded
|
||
Object.keys(characteristics).every(key =>
|
||
typeof ratings[key] === 'number' && ratings[key] >= 1 && ratings[key] <= 10
|
||
) &&
|
||
(isProgressSelect ? Object.keys(progressOptions).includes(progress) : progress.trim() !== '') && // Check progress validity based on input type
|
||
(supportsSeasons ? (formSeasonId === null || seasons.some(s => s.id === formSeasonId)) : true); // Check season selection validity
|
||
|
||
|
||
// If an existing review is present and not in editing mode, show review details instead of the form
|
||
if (existingReview && !isEditing) {
|
||
return (
|
||
<div className="bg-campfire-charcoal rounded-lg shadow-md p-6 text-center border border-campfire-ash/20">
|
||
<p className="text-campfire-light mb-4">Вы уже написали рецензию на это произведение.</p>
|
||
<div className="flex justify-center space-x-4">
|
||
<button
|
||
onClick={() => {
|
||
console.log('ReviewForm: Edit button clicked, setting isEditing to true'); // LOG
|
||
setIsEditing(true);
|
||
}}
|
||
className="btn-secondary flex items-center"
|
||
disabled={isSubmitting}
|
||
>
|
||
<FaEdit className="mr-2" /> Редактировать
|
||
</button>
|
||
{/* Fixed delete button text color */}
|
||
<button
|
||
onClick={handleDelete}
|
||
className="btn-danger flex items-center text-white" // Added text-white class
|
||
disabled={isSubmitting}
|
||
>
|
||
<FaTrashAlt className="mr-2" /> Удалить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Quill modules - define toolbar options
|
||
const modules = {
|
||
toolbar: [
|
||
[{ 'header': [1, 2, false] }],
|
||
['bold', 'italic', 'underline', 'strike', 'blockquote'],
|
||
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||
['link'],
|
||
['clean']
|
||
],
|
||
};
|
||
|
||
const formats = [
|
||
'header',
|
||
'bold', 'italic', 'underline', 'strike', 'blockquote',
|
||
'list', 'bullet',
|
||
'link'
|
||
];
|
||
|
||
|
||
return (
|
||
<div className="bg-campfire-charcoal rounded-lg shadow-md p-6 border border-campfire-ash/20">
|
||
<h2 className="text-xl font-bold text-campfire-light mb-6 border-b border-campfire-ash/20 pb-4">
|
||
{existingReview ? 'Редактировать рецензию' : 'Написать рецензию'}
|
||
</h2>
|
||
|
||
{/* Add a log to see if this form section is being rendered */}
|
||
{existingReview && isEditing && console.log('ReviewForm: Rendering Edit Form')}
|
||
{!existingReview && console.log('ReviewForm: Rendering Create Form')}
|
||
|
||
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-6">
|
||
<div className="md:col-span-2">
|
||
|
||
{/* Season Selection (if media supports seasons) */}
|
||
{supportsSeasons && seasons.length > 0 && (
|
||
<div className="mb-6">
|
||
<label htmlFor="season-select" className="block mb-2 text-campfire-light">
|
||
Сезон <span className="text-red-500">*</span>
|
||
</label>
|
||
<select
|
||
id="season-select"
|
||
value={formSeasonId === null ? '' : formSeasonId} // Use empty string for null to match option value
|
||
onChange={handleFormSeasonChange}
|
||
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={supportsSeasons && formSeasonId === undefined} // Require if seasons are supported and no season is selected yet
|
||
>
|
||
<option value="">Общее</option> {/* Option for overall review */}
|
||
{seasons.map(season => (
|
||
<option key={season.id} value={season.id}>
|
||
Сезон {season.season_number} {season.title ? ` - ${season.title}` : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
|
||
{/* Progress Input - Conditional based on progressType */}
|
||
<div className="mb-6">
|
||
<label className="block mb-2 text-campfire-light">
|
||
{progressType === 'hours'
|
||
? 'Часов проведено'
|
||
: progressType === 'watched'
|
||
? 'Статус просмотра'
|
||
: progressType === 'completed'
|
||
? 'Статус прохождения'
|
||
: 'Прогресс' // Fallback label
|
||
} <span className="text-red-500">*</span>
|
||
</label>
|
||
{isProgressSelect ? (
|
||
// Segmented control for watched/completed status
|
||
<div className="flex rounded-md overflow-hidden border border-campfire-ash/30">
|
||
{Object.entries(progressOptions).map(([key, label]) => (
|
||
<button
|
||
key={key}
|
||
type="button" // Use type="button" to prevent form submission
|
||
onClick={() => handleProgressChange(key)}
|
||
className={`flex-1 text-center py-2 text-sm font-medium transition-colors duration-200
|
||
${progress === key
|
||
? 'bg-campfire-amber text-campfire-dark'
|
||
: 'bg-campfire-dark text-campfire-ash hover:bg-campfire-ash/20'
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
// Text input for hours (for games with progressType 'hours') or fallback
|
||
<input
|
||
type={progressType === 'hours' ? 'number' : 'text'} // Use number type for hours, text for others
|
||
min={progressType === 'hours' ? "0" : undefined} // Hours cannot be negative
|
||
placeholder={progressType === 'hours' ? "Введите количество часов..." : "Введите прогресс..."}
|
||
value={progress}
|
||
onChange={(e) => handleProgressChange(e.target.value)}
|
||
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
|
||
/>
|
||
)}
|
||
{/* Add a hidden required input to satisfy HTML5 validation */}
|
||
{/* This hidden input is a fallback; actual validation is in isFormValid */}
|
||
{/* Removed the hidden input as isFormValid handles validation */}
|
||
</div>
|
||
|
||
|
||
{/* Rating Inputs - Using FlameRatingInput */}
|
||
<div className="space-y-8 mb-6"> {/* Increased spacing */}
|
||
{/* Iterate over characteristics provided by the media */}
|
||
{Object.entries(characteristics).map(([key, label]) => (
|
||
<div key={key} className="space-y-2">
|
||
<div className="flex justify-between items-center mb-1">
|
||
<label htmlFor={`rating-${key}`} className="block text-sm font-medium text-campfire-light">{label}</label>
|
||
{/* Display value - Changed FaStar to FaFire */}
|
||
<span className="flex items-center text-campfire-amber font-bold text-lg"> {/* Made value larger/bolder */}
|
||
<FaFire className="mr-1 text-base" /> {/* Adjusted icon size */}
|
||
{ratings[key] !== undefined ? ratings[key] : 5}
|
||
</span>
|
||
</div>
|
||
{/* Flame Rating Input Component */}
|
||
<FlameRatingInput
|
||
value={ratings[key] !== undefined ? ratings[key] : 5}
|
||
onChange={(value) => handleRatingChange(key, value)}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Review Text - Using ReactQuill */}
|
||
<div className="mb-6">
|
||
<label htmlFor="review-content" className="block mb-2 text-campfire-light text-sm font-medium">
|
||
Ваша рецензия <span className="text-red-500">*</span>
|
||
</label>
|
||
<ReactQuill
|
||
theme="snow" // Use the snow theme
|
||
value={content}
|
||
onChange={setContent}
|
||
modules={modules}
|
||
formats={formats}
|
||
placeholder="Поделитесь своими мыслями об этом произведении..."
|
||
className="bg-campfire-dark text-campfire-light rounded-md border border-campfire-ash/30 quill-custom" // Added custom class
|
||
/>
|
||
{/* Add custom styles for Quill */}
|
||
<style>{`
|
||
.quill-custom .ql-toolbar {
|
||
background: #3a332d; /* campfire-charcoal */
|
||
border-top-left-radius: 0.375rem; /* rounded-md */
|
||
border-top-right-radius: 0.375rem; /* rounded-md */
|
||
border-color: #5a524a; /* campfire-ash/30 */
|
||
}
|
||
.quill-custom .ql-container {
|
||
border-bottom-left-radius: 0.375rem; /* rounded-md */
|
||
border-bottom-right-radius: 0.375rem; /* rounded-md */
|
||
border-color: #5a524a; /* campfire-ash/30 */
|
||
}
|
||
.quill-custom .ql-editor {
|
||
min-height: 150px; /* Adjust height as needed */
|
||
color: #f0e7db; /* campfire-light */
|
||
}
|
||
.quill-custom .ql-editor.ql-blank::before {
|
||
color: #a09a93; /* campfire-ash */
|
||
font-style: normal; /* Remove italic */
|
||
}
|
||
/* Style for toolbar buttons */
|
||
.quill-custom .ql-toolbar button {
|
||
color: #f0e7db; /* campfire-light */
|
||
}
|
||
.quill-custom .ql-toolbar button:hover {
|
||
color: #f59e0b; /* campfire-amber */
|
||
}
|
||
.quill-custom .ql-toolbar .ql-active {
|
||
color: #f59e0b; /* campfire-amber */
|
||
}
|
||
/* Style for dropdowns */
|
||
.quill-custom .ql-toolbar .ql-picker {
|
||
color: #f0e7db; /* campfire-light */
|
||
}
|
||
.quill-custom .ql-toolbar .ql-picker:hover {
|
||
color: #f59e0b; /* campfire-amber */
|
||
}
|
||
.quill-custom .ql-toolbar .ql-picker-label {
|
||
color: #f0e7db; /* campfire-light */
|
||
}
|
||
.quill-custom .ql-toolbar .ql-picker-label:hover {
|
||
color: #f59e0b; /* campfire-amber */
|
||
}
|
||
.quill-custom .ql-toolbar .ql-picker-label.ql-active {
|
||
color: #f59e0b; /* campfire-amber */
|
||
}
|
||
.quill-custom .ql-toolbar .ql-picker-item:hover {
|
||
color: #f59e0b; /* campfire-amber */
|
||
}
|
||
.quill-custom .ql-toolbar .ql-picker-item.ql-selected {
|
||
color: #f59e0b; /* campfire-amber */
|
||
}
|
||
.quill-custom .ql-tooltip {
|
||
background-color: #3a332d; /* campfire-charcoal */
|
||
color: #f0e7db; /* campfire-light */
|
||
border-color: #5a524a; /* campfire-ash/30 */
|
||
}
|
||
.quill-custom .ql-tooltip input[type=text] {
|
||
background-color: #2a2623; /* campfire-dark */
|
||
color: #f0e7db; /* campfire-light */
|
||
border-color: #5a524a; /* campfire-ash/30 */
|
||
}
|
||
`}</style>
|
||
</div>
|
||
|
||
{/* Spoiler Checkbox */}
|
||
<div className="mb-6 flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
id="spoiler-check"
|
||
checked={hasSpoilers}
|
||
onChange={(e) => setHasSpoilers(e.target.checked)}
|
||
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="spoiler-check" className="ml-2 text-campfire-light text-sm font-medium cursor-pointer">
|
||
Эта рецензия содержит спойлеры
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Rating Chart Preview */}
|
||
<div className="md:col-span-1">
|
||
<p className="text-center text-campfire-light mb-4 font-semibold">Предварительный просмотр вашей оценки</p>
|
||
<RatingChart
|
||
ratings={Object.entries(ratings).reduce((acc, [key, value]) => {
|
||
if (characteristics.hasOwnProperty(key) && typeof value === 'number' && value >= 1 && value <= 10) {
|
||
acc[key] = value;
|
||
}
|
||
return acc;
|
||
}, {})}
|
||
labels={characteristics} // Pass the characteristics labels
|
||
size="medium"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Submit Button */}
|
||
<div className="flex justify-end mt-6"> {/* Added mt-6 for spacing */}
|
||
<button
|
||
type="submit"
|
||
disabled={isSubmitting || !isFormValid}
|
||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||
>
|
||
{isSubmitting ? (existingReview ? 'Сохранение...' : 'Отправка...') : (existingReview ? 'Сохранить изменения' : 'Отправить рецензию')}
|
||
</button>
|
||
</div>
|
||
{/* Corrected error message condition */}
|
||
{!isFormValid && (content.trim() === '' || content === '<p><br></p>' || !(isProgressSelect ? Object.keys(progressOptions).includes(progress) : progress.trim() !== '') || Object.keys(characteristics).some(key => typeof ratings[key] !== 'number' || ratings[key] < 1 || ratings[key] > 10) || (supportsSeasons && formSeasonId === undefined)) && (
|
||
<p className="text-status-error text-sm mt-2 text-right">
|
||
Пожалуйста, заполните все обязательные поля (рецензия, прогресс, все оценки от 1 до 10{supportsSeasons ? ', сезон' : ''}).
|
||
</p>
|
||
)}
|
||
{Object.keys(characteristics).length === 0 && (
|
||
<p className="text-status-error text-sm mt-2 text-right">
|
||
Характеристики для этого произведения не загружены.
|
||
</p>
|
||
)}
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default ReviewForm;
|