From 5311588baf3c6acfc971cb014a142d6b4b153aa1 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 20 May 2026 16:42:22 +0200 Subject: [PATCH] fix(feedback): three guest-mode bugs reported in #538 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up three of the four bugs from @Tietge86's report. Bug 4 (recovery flow) needs network-tab data from the reporter before a fix makes sense; commented on the issue asking for the HTTP status from POST /gallery/:slug/guest/recover. Bug 1 — "Liked" filter empty in guest identity mode (GalleryView.tsx) The feedback filter was scoping by `photo.like_count > 0`, which is the global aggregate across all guests. In guest identity mode the filter intent is "show MY picks", so a guest who'd liked photos that nobody else had touched got an empty grid. Fix: pull the current guest's interactions from /my-feedback (already keyed by x-guest-token in the api interceptor) into per-type photo-id Sets and filter against those when identity_mode === 'guest'. Falls back to the aggregate-count check in simple mode where there's no per-person identity to scope by. Same per-guest scoping applied to the chip-count labels ("Liked (N)" etc.) so the chip number matches what the filter actually surfaces — otherwise the chip says one count globally and the filter shows a different (smaller) one, which is the same UX cliff #538 originally surfaced. The /my-feedback query is gated on isGuestIdentityMode (not on filterType being feedback-related) so the chip counts are populated on first render. One extra request per gallery load in guest mode; payload is tiny. Bug 2 — Liked state on PhotoLikes button invisible bg-red-50 text-red-600 is barely visible against most themes, especially dark + brand-coloured backgrounds. Switch to the same filled state the lightbox toolbar already uses (bg-red-500/80 text-white) so the like registers visually. Heart icon's fill-current was already there for the liked state — unchanged. Bug 3 — Aggregate like count leaks in lightbox toolbar PhotoLightbox.tsx rendered {likeCount} unconditionally next to the inline heart button. When the admin has show_feedback_to_guests off, guests still saw how many other guests had liked a photo (the count is an admin-only metric in that mode). Gate the span on feedbackSettings?.show_feedback_to_guests, matching how the rest of the lightbox toolbar treats that toggle. Also added show_feedback_to_guests to the local feedbackSettings TS type (backend already returns it). Tests: tsc --noEmit clean. No new unit tests — bug 1 is data-flow plumbing best validated via manual / e2e (the existing my-feedback endpoint and feedbackService are already covered upstream); bugs 2/3 are CSS and a conditional render. Refs: #538 (bugs 1, 2, 3 of 4) --- .../src/components/gallery/GalleryView.tsx | 98 +++++++++++++++---- .../src/components/gallery/PhotoLightbox.tsx | 9 +- .../src/components/gallery/PhotoLikes.tsx | 8 +- 3 files changed, 94 insertions(+), 21 deletions(-) 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')}