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>
This commit is contained in:
2025-07-22 15:08:52 +02:00
parent 2624ea6130
commit dc1419c051
28 changed files with 4312 additions and 15 deletions
@@ -0,0 +1,274 @@
import React from 'react';
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye } from 'lucide-react';
import { Card } from '../common';
import { useTranslation } from 'react-i18next';
interface FeedbackSettingsProps {
settings: FeedbackSettings;
onChange: (settings: FeedbackSettings) => void;
className?: string;
}
interface FeedbackSettings {
feedback_enabled: boolean;
allow_ratings: boolean;
allow_likes: boolean;
allow_comments: boolean;
allow_favorites: boolean;
require_name_email: boolean;
moderate_comments: boolean;
show_feedback_to_guests: boolean;
enable_rate_limiting: boolean;
rate_limit_window_minutes?: number;
rate_limit_max_requests?: number;
}
export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
settings,
onChange,
className = ''
}) => {
const { t } = useTranslation();
const handleToggle = (field: keyof FeedbackSettings) => {
onChange({
...settings,
[field]: !settings[field]
});
};
const handleNumberChange = (field: keyof FeedbackSettings, value: string) => {
const numValue = parseInt(value, 10);
if (!isNaN(numValue)) {
onChange({
...settings,
[field]: numValue
});
}
};
return (
<Card className={className}>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
<MessageSquare className="w-5 h-5" />
{t('feedback.settings.title', 'Guest Feedback Settings')}
</h2>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={settings.feedback_enabled}
onChange={() => handleToggle('feedback_enabled')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<span className="text-sm font-medium text-neutral-700">
{t('feedback.settings.enableFeedback', 'Enable feedback')}
</span>
</label>
</div>
{settings.feedback_enabled && (
<>
{/* Feedback Types */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-700">
{t('feedback.settings.feedbackTypes', 'Feedback Types')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
<input
type="checkbox"
checked={settings.allow_ratings}
onChange={() => handleToggle('allow_ratings')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<Star className="w-5 h-5 text-neutral-600" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900">
{t('feedback.settings.ratings', 'Star Ratings')}
</div>
<div className="text-xs text-neutral-500">
{t('feedback.settings.ratingsDesc', 'Allow guests to rate photos (1-5 stars)')}
</div>
</div>
</label>
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
<input
type="checkbox"
checked={settings.allow_likes}
onChange={() => handleToggle('allow_likes')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<Heart className="w-5 h-5 text-neutral-600" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900">
{t('feedback.settings.likes', 'Likes')}
</div>
<div className="text-xs text-neutral-500">
{t('feedback.settings.likesDesc', 'Simple like/unlike functionality')}
</div>
</div>
</label>
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
<input
type="checkbox"
checked={settings.allow_comments}
onChange={() => handleToggle('allow_comments')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<MessageSquare className="w-5 h-5 text-neutral-600" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900">
{t('feedback.settings.comments', 'Comments')}
</div>
<div className="text-xs text-neutral-500">
{t('feedback.settings.commentsDesc', 'Text comments on photos')}
</div>
</div>
</label>
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
<input
type="checkbox"
checked={settings.allow_favorites}
onChange={() => handleToggle('allow_favorites')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<Bookmark className="w-5 h-5 text-neutral-600" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900">
{t('feedback.settings.favorites', 'Favorites')}
</div>
<div className="text-xs text-neutral-500">
{t('feedback.settings.favoritesDesc', 'Mark photos as favorites')}
</div>
</div>
</label>
</div>
</div>
<div className="border-t pt-4" />
{/* Privacy & Moderation */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-700">
{t('feedback.settings.privacyModeration', 'Privacy & Moderation')}
</h3>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.require_name_email}
onChange={() => handleToggle('require_name_email')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900">
{t('feedback.settings.requireInfo', 'Require Name & Email')}
</div>
<div className="text-xs text-neutral-500">
{t('feedback.settings.requireInfoDesc', 'Guests must provide name and email to leave feedback')}
</div>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.moderate_comments}
onChange={() => handleToggle('moderate_comments')}
disabled={!settings.allow_comments}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 disabled:opacity-50"
/>
<Shield className="w-5 h-5 text-neutral-600" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900">
{t('feedback.settings.moderateComments', 'Moderate Comments')}
</div>
<div className="text-xs text-neutral-500">
{t('feedback.settings.moderateCommentsDesc', 'Comments require approval before being visible')}
</div>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.show_feedback_to_guests}
onChange={() => handleToggle('show_feedback_to_guests')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<Eye className="w-5 h-5 text-neutral-600" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900">
{t('feedback.settings.showToGuests', 'Show Feedback to Guests')}
</div>
<div className="text-xs text-neutral-500">
{t('feedback.settings.showToGuestsDesc', 'Other guests can see ratings, likes, and approved comments')}
</div>
</div>
</label>
</div>
</div>
<div className="border-t pt-4" />
{/* Rate Limiting */}
<div className="space-y-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.enable_rate_limiting}
onChange={() => handleToggle('enable_rate_limiting')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900">
{t('feedback.settings.enableRateLimiting', 'Enable Rate Limiting')}
</div>
<div className="text-xs text-neutral-500">
{t('feedback.settings.rateLimitingDesc', 'Prevent spam by limiting feedback frequency')}
</div>
</div>
</label>
{settings.enable_rate_limiting && (
<div className="grid grid-cols-2 gap-4 ml-7">
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">
{t('feedback.settings.timeWindow', 'Time Window (minutes)')}
</label>
<input
type="number"
min="1"
max="60"
value={settings.rate_limit_window_minutes || 15}
onChange={(e) => handleNumberChange('rate_limit_window_minutes', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">
{t('feedback.settings.maxRequests', 'Max Requests')}
</label>
<input
type="number"
min="1"
max="100"
value={settings.rate_limit_max_requests || 10}
onChange={(e) => handleNumberChange('rate_limit_max_requests', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
)}
</div>
</>
)}
</div>
</Card>
);
};
+2 -1
View File
@@ -26,4 +26,5 @@ export { GalleryPreview } from './GalleryPreview';
export { BackupDashboard } from './BackupDashboard';
export { BackupConfiguration } from './BackupConfiguration';
export { BackupHistory } from './BackupHistory';
export { RestoreWizard } from './RestoreWizard';
export { RestoreWizard } from './RestoreWizard';
export { FeedbackSettings } from './FeedbackSettings';
@@ -0,0 +1,250 @@
import React, { useState, useRef, useEffect } from 'react';
import { MessageSquare, Send, User, Loader2 } 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';
import { format } from 'date-fns';
import { Button, Input } from '../common';
import type { PhotoFeedback } from '../../services/feedback.service';
interface PhotoCommentsProps {
photoId: string;
gallerySlug: string;
comments: PhotoFeedback[];
isEnabled: boolean;
requireNameEmail: boolean;
showToGuests: boolean;
onCommentAdded?: () => void;
}
export const PhotoComments: React.FC<PhotoCommentsProps> = ({
photoId,
gallerySlug,
comments,
isEnabled,
requireNameEmail,
showToGuests,
onCommentAdded
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [showCommentForm, setShowCommentForm] = useState(false);
const [commentText, setCommentText] = useState('');
const [guestName, setGuestName] = useState('');
const [guestEmail, setGuestEmail] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, [commentText]);
const submitCommentMutation = useMutation({
mutationFn: (data: any) =>
feedbackService.submitFeedback(gallerySlug, photoId, {
feedback_type: 'comment',
comment_text: data.comment_text,
guest_name: data.guest_name,
guest_email: data.guest_email
}),
onSuccess: (response) => {
setCommentText('');
setShowCommentForm(false);
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
if (response.message) {
toast.info(response.message);
} else {
toast.success(t('feedback.commentSubmitted', 'Comment submitted'));
}
if (onCommentAdded) {
onCommentAdded();
}
},
onError: (error: any) => {
if (error.response?.status === 429) {
toast.error(t('feedback.rateLimited', 'Please wait before commenting again'));
} else if (error.response?.data?.errors) {
setErrors(error.response.data.errors);
} else {
toast.error(t('feedback.commentError', 'Failed to submit comment'));
}
}
});
const handleSubmitComment = (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Validate
const newErrors: Record<string, string> = {};
if (!commentText.trim()) {
newErrors.comment_text = t('feedback.commentRequired', 'Comment is required');
}
if (requireNameEmail) {
if (!guestName.trim()) {
newErrors.guest_name = t('feedback.nameRequired', 'Name is required');
}
if (!guestEmail.trim()) {
newErrors.guest_email = t('feedback.emailRequired', 'Email is required');
}
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
submitCommentMutation.mutate({
comment_text: commentText.trim(),
guest_name: guestName.trim(),
guest_email: guestEmail.trim()
});
};
if (!isEnabled) return null;
// Filter comments based on visibility settings
const visibleComments = showToGuests
? comments.filter(c => c.is_approved && !c.is_hidden)
: comments.filter(c => c.is_mine);
return (
<div className="space-y-4">
{/* Comments Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-900 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
{t('feedback.comments', 'Comments')}
{visibleComments.length > 0 && (
<span className="text-neutral-500">({visibleComments.length})</span>
)}
</h3>
{!showCommentForm && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowCommentForm(true)}
>
{t('feedback.addComment', 'Add Comment')}
</Button>
)}
</div>
{/* Comment Form */}
{showCommentForm && (
<form onSubmit={handleSubmitComment} className="space-y-3 p-3 bg-neutral-50 rounded-lg">
{requireNameEmail && (
<div className="grid grid-cols-2 gap-3">
<Input
placeholder={t('feedback.yourName', 'Your name')}
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
error={errors.guest_name}
size="sm"
/>
<Input
type="email"
placeholder={t('feedback.yourEmail', 'Your email')}
value={guestEmail}
onChange={(e) => setGuestEmail(e.target.value)}
error={errors.guest_email}
size="sm"
/>
</div>
)}
<div>
<textarea
ref={textareaRef}
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder={t('feedback.writeComment', 'Write a comment...')}
className={`w-full px-3 py-2 text-sm border rounded-lg resize-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
errors.comment_text ? 'border-red-500' : 'border-neutral-300'
}`}
rows={2}
maxLength={500}
/>
{errors.comment_text && (
<p className="text-xs text-red-600 mt-1">{errors.comment_text}</p>
)}
<p className="text-xs text-neutral-500 mt-1">
{commentText.length}/500
</p>
</div>
<div className="flex gap-2">
<Button
type="submit"
size="sm"
variant="primary"
leftIcon={submitCommentMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
disabled={submitCommentMutation.isPending}
>
{t('feedback.submit', 'Submit')}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setShowCommentForm(false);
setCommentText('');
setErrors({});
}}
>
{t('common.cancel', 'Cancel')}
</Button>
</div>
</form>
)}
{/* Comments List */}
{visibleComments.length > 0 && (
<div className="space-y-3">
{visibleComments.map((comment) => (
<div key={comment.id} className="flex gap-3">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-neutral-200 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-neutral-600" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 mb-1">
<span className="text-sm font-medium text-neutral-900">
{comment.guest_name || t('feedback.anonymous', 'Anonymous')}
</span>
<span className="text-xs text-neutral-500">
{format(new Date(comment.created_at), 'PP')}
</span>
{comment.is_mine && !comment.is_approved && (
<span className="text-xs text-orange-600">
{t('feedback.pendingApproval', 'Pending approval')}
</span>
)}
</div>
<p className="text-sm text-neutral-700 break-words">
{comment.comment_text}
</p>
</div>
</div>
))}
</div>
)}
{/* Empty State */}
{visibleComments.length === 0 && !showCommentForm && (
<p className="text-sm text-neutral-500 text-center py-4">
{t('feedback.noComments', 'No comments yet. Be the first to comment!')}
</p>
)}
</div>
);
};
@@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { Bookmark } 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 PhotoFavoritesProps {
photoId: string;
gallerySlug: string;
isFavorited: boolean;
favoriteCount: number;
isEnabled: boolean;
onFavoriteChange?: (favorited: boolean) => void;
}
export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
photoId,
gallerySlug,
isFavorited,
favoriteCount,
isEnabled,
onFavoriteChange
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false);
const [animating, setAnimating] = useState(false);
const submitFavoriteMutation = useMutation({
mutationFn: () =>
feedbackService.submitFeedback(gallerySlug, photoId, {
feedback_type: 'favorite'
}),
onMutate: async () => {
setIsSubmitting(true);
setAnimating(true);
// Optimistic update
if (onFavoriteChange) {
onFavoriteChange(!isFavorited);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
},
onError: (error: any) => {
// Revert optimistic update
if (onFavoriteChange) {
onFavoriteChange(isFavorited);
}
if (error.response?.status === 429) {
toast.error(t('feedback.rateLimited', 'Please wait before favoriting again'));
} else {
toast.error(t('feedback.favoriteError', 'Failed to update favorite'));
}
},
onSettled: () => {
setIsSubmitting(false);
setTimeout(() => setAnimating(false), 300);
}
});
const handleFavoriteClick = () => {
if (!isEnabled || isSubmitting) return;
submitFavoriteMutation.mutate();
};
if (!isEnabled) return null;
return (
<button
onClick={handleFavoriteClick}
disabled={isSubmitting}
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
isFavorited
? 'bg-amber-50 text-amber-600 hover:bg-amber-100'
: 'bg-neutral-50 text-neutral-600 hover:bg-neutral-100'
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
aria-label={isFavorited ? t('feedback.unfavorite', 'Remove from favorites') : t('feedback.favorite', 'Add to favorites')}
>
<Bookmark
className={`w-5 h-5 transition-all ${
animating ? 'scale-125' : 'scale-100'
} ${
isFavorited ? 'fill-current' : 'group-hover:scale-110'
}`}
/>
<span className="text-sm font-medium">
{favoriteCount > 0 ? favoriteCount : ''}
</span>
</button>
);
};
@@ -0,0 +1,155 @@
import React, { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { PhotoRating } from './PhotoRating';
import { PhotoLikes } from './PhotoLikes';
import { PhotoComments } from './PhotoComments';
import { PhotoFavorites } from './PhotoFavorites';
import { Skeleton } from '../common';
import type { FeedbackSettings } from '../../services/feedback.service';
interface PhotoFeedbackProps {
photoId: string;
gallerySlug: string;
className?: string;
showComments?: boolean;
onFeedbackUpdate?: () => void;
}
export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
photoId,
gallerySlug,
className = '',
showComments = true,
onFeedbackUpdate
}) => {
// Fetch feedback settings for the gallery
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ['gallery-feedback-settings', gallerySlug],
queryFn: () => feedbackService.getGalleryFeedbackSettings(gallerySlug),
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Fetch feedback data for the photo
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
queryKey: ['photo-feedback', gallerySlug, photoId],
queryFn: () => feedbackService.getPhotoFeedback(gallerySlug, photoId),
enabled: !!settings?.feedback_enabled,
});
// Local state for optimistic updates
const [currentRating, setCurrentRating] = useState(0);
const [isLiked, setIsLiked] = useState(false);
const [isFavorited, setIsFavorited] = useState(false);
const [likeCount, setLikeCount] = useState(0);
const [favoriteCount, setFavoriteCount] = useState(0);
// Update local state when data loads
useEffect(() => {
if (feedbackData) {
setCurrentRating(feedbackData.my_feedback.rating || 0);
setIsLiked(feedbackData.my_feedback.liked);
setIsFavorited(feedbackData.my_feedback.favorited);
setLikeCount(feedbackData.summary.like_count);
setFavoriteCount(feedbackData.summary.favorite_count);
}
}, [feedbackData]);
// Handle optimistic updates
const handleRatingChange = (rating: number) => {
setCurrentRating(rating);
if (onFeedbackUpdate) onFeedbackUpdate();
};
const handleLikeChange = (liked: boolean) => {
setIsLiked(liked);
setLikeCount(prev => liked ? prev + 1 : Math.max(0, prev - 1));
if (onFeedbackUpdate) onFeedbackUpdate();
};
const handleFavoriteChange = (favorited: boolean) => {
setIsFavorited(favorited);
setFavoriteCount(prev => favorited ? prev + 1 : Math.max(0, prev - 1));
if (onFeedbackUpdate) onFeedbackUpdate();
};
if (settingsLoading) {
return (
<div className={`space-y-3 ${className}`}>
<Skeleton className="h-8 w-32" />
<Skeleton className="h-10 w-full" />
</div>
);
}
if (!settings?.feedback_enabled) {
return null;
}
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
settings.allow_comments || settings.allow_favorites;
if (!hasAnyFeedbackType) {
return null;
}
return (
<div className={`space-y-4 ${className}`}>
{/* Rating Section */}
{settings.allow_ratings && (
<PhotoRating
photoId={photoId}
gallerySlug={gallerySlug}
currentRating={currentRating}
averageRating={feedbackData?.summary.average_rating}
totalRatings={feedbackData?.summary.total_ratings}
isEnabled={true}
onRatingChange={handleRatingChange}
/>
)}
{/* Action Buttons */}
{(settings.allow_likes || settings.allow_favorites) && (
<div className="flex items-center gap-2">
{settings.allow_likes && (
<PhotoLikes
photoId={photoId}
gallerySlug={gallerySlug}
isLiked={isLiked}
likeCount={likeCount}
isEnabled={true}
onLikeChange={handleLikeChange}
/>
)}
{settings.allow_favorites && (
<PhotoFavorites
photoId={photoId}
gallerySlug={gallerySlug}
isFavorited={isFavorited}
favoriteCount={favoriteCount}
isEnabled={true}
onFavoriteChange={handleFavoriteChange}
/>
)}
</div>
)}
{/* Comments Section */}
{settings.allow_comments && showComments && (
<div className="border-t pt-4">
<PhotoComments
photoId={photoId}
gallerySlug={gallerySlug}
comments={feedbackData?.feedback || []}
isEnabled={true}
requireNameEmail={settings.require_name_email || false}
showToGuests={settings.show_feedback_to_guests || false}
onCommentAdded={() => {
if (onFeedbackUpdate) onFeedbackUpdate();
}}
/>
</div>
)}
</div>
);
};
@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare } from 'lucide-react';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
import { PhotoFeedback } from './PhotoFeedback';
interface PhotoLightboxProps {
photos: Photo[];
@@ -23,6 +24,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [touchDistance, setTouchDistance] = useState<number | null>(null);
const [showFeedback, setShowFeedback] = useState(false);
const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex];
@@ -224,6 +226,14 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
>
<Download className="w-5 h-5 text-white" />
</button>
<button
onClick={() => setShowFeedback(!showFeedback)}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
aria-label="Toggle feedback"
>
<MessageSquare className="w-5 h-5 text-white" />
</button>
</div>
</div>
</div>
@@ -259,6 +269,29 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden z-20">
Swipe to navigate
</div>
{/* Feedback Panel */}
{showFeedback && (
<div className="absolute right-0 top-0 bottom-0 w-96 bg-white shadow-xl z-20 overflow-y-auto">
<div className="sticky top-0 bg-white border-b px-4 py-3 flex items-center justify-between">
<h3 className="font-semibold text-neutral-900">Photo Feedback</h3>
<button
onClick={() => setShowFeedback(false)}
className="p-1 hover:bg-neutral-100 rounded transition-colors"
aria-label="Close feedback"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4">
<PhotoFeedback
photoId={currentPhoto.id}
gallerySlug={slug}
showComments={true}
/>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { Heart } 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 PhotoLikesProps {
photoId: string;
gallerySlug: string;
isLiked: boolean;
likeCount: number;
isEnabled: boolean;
onLikeChange?: (liked: boolean) => void;
}
export const PhotoLikes: React.FC<PhotoLikesProps> = ({
photoId,
gallerySlug,
isLiked,
likeCount,
isEnabled,
onLikeChange
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false);
const [animating, setAnimating] = useState(false);
const submitLikeMutation = useMutation({
mutationFn: () =>
feedbackService.submitFeedback(gallerySlug, photoId, {
feedback_type: 'like'
}),
onMutate: async () => {
setIsSubmitting(true);
setAnimating(true);
// Optimistic update
if (onLikeChange) {
onLikeChange(!isLiked);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
},
onError: (error: any) => {
// Revert optimistic update
if (onLikeChange) {
onLikeChange(isLiked);
}
if (error.response?.status === 429) {
toast.error(t('feedback.rateLimited', 'Please wait before liking again'));
} else {
toast.error(t('feedback.likeError', 'Failed to update like'));
}
},
onSettled: () => {
setIsSubmitting(false);
setTimeout(() => setAnimating(false), 300);
}
});
const handleLikeClick = () => {
if (!isEnabled || isSubmitting) return;
submitLikeMutation.mutate();
};
if (!isEnabled) return null;
return (
<button
onClick={handleLikeClick}
disabled={isSubmitting}
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
isLiked
? 'bg-red-50 text-red-600 hover:bg-red-100'
: 'bg-neutral-50 text-neutral-600 hover:bg-neutral-100'
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
aria-label={isLiked ? t('feedback.unlike', 'Unlike') : t('feedback.like', 'Like')}
>
<Heart
className={`w-5 h-5 transition-all ${
animating ? 'scale-125' : 'scale-100'
} ${
isLiked ? 'fill-current' : 'group-hover:scale-110'
}`}
/>
<span className="text-sm font-medium">
{likeCount > 0 ? likeCount : ''}
</span>
</button>
);
};
@@ -0,0 +1,113 @@
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>
);
};
+6 -1
View File
@@ -5,4 +5,9 @@ export { ExpirationBanner } from './ExpirationBanner';
export { CountdownTimer } from './CountdownTimer';
export { GalleryLayout } from './GalleryLayout';
export { PhotoFilterBar } from './PhotoFilterBar';
export { UserPhotoUpload } from './UserPhotoUpload';
export { UserPhotoUpload } from './UserPhotoUpload';
export { PhotoFeedback } from './PhotoFeedback';
export { PhotoRating } from './PhotoRating';
export { PhotoLikes } from './PhotoLikes';
export { PhotoComments } from './PhotoComments';
export { PhotoFavorites } from './PhotoFavorites';