Files
picpeak/frontend/src/components/gallery/PhotoRating.tsx
T
paul b31f7e6f34
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m26s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 48s
Version and Release / trigger-drone (push) Successful in 3s
feat: implement gallery feedback system with version tracking for backups
Gallery Feedback Features:
- Add feedback system allowing ratings, likes, comments, and favorites on photos
- Implement admin controls for enabling/disabling feedback per event
- Add content moderation with word filters and spam detection
- Implement rate limiting to prevent abuse (10 requests/15min per type)
- Create comprehensive admin interface for feedback management
- Add analytics dashboard for feedback insights
- Export feedback data when archiving events

Frontend Components:
- PhotoRating: 5-star rating system with optimistic updates
- PhotoLikes: Like/unlike with animation
- PhotoComments: Threaded comments with moderation
- PhotoFavorites: Bookmark functionality
- FeedbackSettings: Admin configuration panel
- EventFeedbackPage: Complete management interface

Backend Implementation:
- Database migration 033: 4 new tables for feedback system
- RESTful API with proper authorization
- Guest identification via SHA256(IP+UserAgent)
- Automatic backup integration
- Email notification support

Backup Version Tracking:
- Migration 034: Add version columns to backup tables
- Track app version, Node.js version, and DB schema version
- Create restore_history table for tracking restore attempts
- Add version compatibility checking for safe restores
- Configurable version matching requirements

Security & Performance:
- Input validation and sanitization
- Rate limiting per feedback type
- Content moderation system
- Optimistic UI updates
- Efficient database queries with proper indexes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 15:08:52 +02:00

113 lines
3.5 KiB
TypeScript

import React, { useState } from 'react';
import { Star } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { toast } from 'react-toastify';
interface PhotoRatingProps {
photoId: string;
gallerySlug: string;
currentRating?: number;
averageRating?: number;
totalRatings?: number;
isEnabled: boolean;
onRatingChange?: (rating: number) => void;
}
export const PhotoRating: React.FC<PhotoRatingProps> = ({
photoId,
gallerySlug,
currentRating = 0,
averageRating = 0,
totalRatings = 0,
isEnabled,
onRatingChange
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [hoveredRating, setHoveredRating] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const submitRatingMutation = useMutation({
mutationFn: (rating: number) =>
feedbackService.submitFeedback(gallerySlug, photoId, {
feedback_type: 'rating',
rating
}),
onMutate: async (rating) => {
setIsSubmitting(true);
// Optimistic update
if (onRatingChange) {
onRatingChange(rating);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
toast.success(t('feedback.ratingSubmitted', 'Rating submitted'));
},
onError: (error: any) => {
// Revert optimistic update
if (onRatingChange && currentRating) {
onRatingChange(currentRating);
}
if (error.response?.status === 429) {
toast.error(t('feedback.rateLimited', 'Please wait before rating again'));
} else {
toast.error(t('feedback.ratingError', 'Failed to submit rating'));
}
},
onSettled: () => {
setIsSubmitting(false);
}
});
const handleRatingClick = (rating: number) => {
if (!isEnabled || isSubmitting) return;
// If clicking the same rating, remove it
const newRating = rating === currentRating ? 0 : rating;
submitRatingMutation.mutate(newRating);
};
if (!isEnabled) return null;
return (
<div className="flex flex-col items-center gap-2">
{/* Star Rating Input */}
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => handleRatingClick(star)}
onMouseEnter={() => setHoveredRating(star)}
onMouseLeave={() => setHoveredRating(0)}
disabled={isSubmitting}
className={`p-1 transition-all ${
isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:scale-110'
}`}
aria-label={t('feedback.rateStar', 'Rate {{count}} stars', { count: star })}
>
<Star
className={`w-6 h-6 transition-colors ${
star <= (hoveredRating || currentRating)
? 'fill-yellow-500 text-yellow-500'
: 'text-neutral-300 hover:text-yellow-400'
}`}
/>
</button>
))}
</div>
{/* Average Rating Display */}
{totalRatings > 0 && (
<div className="text-sm text-neutral-600">
<span className="font-medium">{averageRating.toFixed(1)}</span>
<span className="text-neutral-400 ml-1">
({t('feedback.ratingsCount', '{{count}} ratings', { count: totalRatings })})
</span>
</div>
)}
</div>
);
};