diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 6d6c1c09..4596e4b9 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -23,6 +23,7 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; import { api } from '../../config/api'; import { Upload, Menu, Eye, EyeOff, Shield } from 'lucide-react'; import { galleryService } from '../../services/gallery.service'; +import { feedbackService } from '../../services/feedback.service'; import { useWatermarkSettings } from '../../hooks/useWatermarkSettings'; import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss'; import { usePublicSettings } from '../../hooks/usePublicSettings'; @@ -229,6 +230,46 @@ export const GalleryView: React.FC = ({ slug, event }) => { } }, [feedbackSettings]); + // In guest identity mode both the "Liked / Favorited / Rated / + // Commented" filters AND the matching chip-count labels need to scope + // to the *current guest's* interactions, not the global aggregates on + // each photo row (#538 bug 1). Pull the current guest's feedback via + // /my-feedback (already keyed by x-guest-token in the api interceptor) + // and build per-type photo-id sets so both consumers below can do + // O(1) lookups. + // + // Always-on in guest mode (not gated on the active filter) because + // the chip counts render whether or not a feedback filter is selected + // — gating on filterType would leave "Liked (0)" stale until the user + // clicks the chip, which is the same UX cliff bug 1 was reporting. + const isGuestIdentityMode = feedbackSettings?.identity_mode === 'guest'; + const { data: myFeedbackRows } = useQuery>({ + queryKey: ['my-feedback', slug], + queryFn: () => feedbackService.getMyFeedback(slug), + enabled: isGuestIdentityMode && !!slug, + staleTime: 30 * 1000, + }); + + const myFeedbackPhotoIds = useMemo(() => { + const sets = { + liked: new Set(), + favorited: new Set(), + rated: new Set(), + commented: new Set(), + }; + if (!myFeedbackRows) return sets; + for (const row of myFeedbackRows) { + if (row.feedback_type === 'like') sets.liked.add(row.photo_id); + else if (row.feedback_type === 'favorite') sets.favorited.add(row.photo_id); + else if (row.feedback_type === 'rating') sets.rated.add(row.photo_id); + else if (row.feedback_type === 'comment') sets.commented.add(row.photo_id); + } + return sets; + }, [myFeedbackRows]); + // Apply branding settings useEffect(() => { if (settingsData) { @@ -444,19 +485,33 @@ export const GalleryView: React.FC = ({ slug, event }) => { ); } - // Apply feedback filter + // Apply feedback filter. In guest identity mode the filter has to + // scope to the *current guest's* interactions (#538 bug 1) — the + // aggregate counts on each photo row are global across all guests, + // which gave an empty grid when the guest had liked photos that + // nobody else had touched. Falls back to the aggregate-count check + // in simple/non-guest mode where there's no per-person identity to + // scope by. switch (filterType) { case 'liked': - photos = photos.filter(photo => (photo.like_count || 0) > 0); + photos = isGuestIdentityMode + ? photos.filter(photo => myFeedbackPhotoIds.liked.has(photo.id)) + : photos.filter(photo => (photo.like_count || 0) > 0); break; case 'favorited': - photos = photos.filter(photo => (photo.favorite_count || 0) > 0); + photos = isGuestIdentityMode + ? photos.filter(photo => myFeedbackPhotoIds.favorited.has(photo.id)) + : photos.filter(photo => (photo.favorite_count || 0) > 0); break; case 'rated': - photos = photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0); + photos = isGuestIdentityMode + ? photos.filter(photo => myFeedbackPhotoIds.rated.has(photo.id)) + : photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0); break; case 'commented': - photos = photos.filter(photo => (photo.comment_count || 0) > 0); + photos = isGuestIdentityMode + ? photos.filter(photo => myFeedbackPhotoIds.commented.has(photo.id)) + : photos.filter(photo => (photo.comment_count || 0) > 0); break; default: break; @@ -502,22 +557,29 @@ export const GalleryView: React.FC = ({ slug, event }) => { } return photos; - }, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, filterType, mediaFilter]); + }, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, filterType, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds]); - const likeCount = useMemo( - () => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0, - [data?.photos] - ); + // Counts shown in the filter chips ("Liked (N)", etc.). In guest + // mode these need to mirror the per-guest filter behaviour above — + // otherwise the chip says "Liked (5)" globally but clicking it + // surfaces 3 (the guest's own subset), which is the same confusing + // mismatch #538 reported for the filter itself. Fall back to the + // global aggregate in simple mode where no per-person identity + // exists. + const likeCount = useMemo(() => { + if (isGuestIdentityMode) return myFeedbackPhotoIds.liked.size; + return data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0; + }, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]); - const favoriteCount = useMemo( - () => data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0, - [data?.photos] - ); + const favoriteCount = useMemo(() => { + if (isGuestIdentityMode) return myFeedbackPhotoIds.favorited.size; + return data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0; + }, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]); - const ratedCount = useMemo( - () => data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0, - [data?.photos] - ); + const ratedCount = useMemo(() => { + if (isGuestIdentityMode) return myFeedbackPhotoIds.rated.size; + return data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0; + }, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]); // Check if downloads are allowed (both event setting and not expired) const allowDownloads = !isExpired && (data?.event?.allow_downloads === true); diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 1c3a5d6b..f8c4cf66 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -78,6 +78,7 @@ export const PhotoLightbox: React.FC = ({ allow_likes?: boolean; allow_ratings?: boolean; allow_comments?: boolean; + show_feedback_to_guests?: boolean; require_name_email?: boolean; } | null>(null); const [myLiked, setMyLiked] = useState(false); @@ -663,7 +664,13 @@ export const PhotoLightbox: React.FC = ({ > - {likeCount} + {/* Aggregate like count is admin-only when the admin + has hidden feedback from guests (#538 bug 3). Without + this gate, a guest could see how many other guests + liked a photo even with show_feedback_to_guests off. */} + {feedbackSettings?.show_feedback_to_guests && ( + {likeCount} + )} )} diff --git a/frontend/src/components/gallery/PhotoLikes.tsx b/frontend/src/components/gallery/PhotoLikes.tsx index d9561094..b57d3382 100644 --- a/frontend/src/components/gallery/PhotoLikes.tsx +++ b/frontend/src/components/gallery/PhotoLikes.tsx @@ -111,8 +111,12 @@ export const PhotoLikes: React.FC = ({ 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' + // Filled solid-red state matches the lightbox toolbar (#538 bug + // 2). The previous `bg-red-50 text-red-600` was almost invisible + // — particularly on dark themes and coloured gallery backgrounds + // — so the user couldn't tell the like had registered. + isLiked + ? 'bg-red-500/80 text-white hover:bg-red-500' : 'bg-surface text-muted-theme hover:bg-black/10' } ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`} aria-label={isLiked ? t('feedback.unlike', 'Unlike') : t('feedback.like', 'Like')}