CampFireCritics/src/components/reviews/ReviewForm.jsx

514 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 { 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;