From 791e9974eb4c81cc4b095f9b604eccd708fb3a66 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 31 May 2026 22:47:16 +0200 Subject: [PATCH] fix(gallery): preserve per-viewer is_liked across hard refresh (#590 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-session toggle fix in d292b9f handles click 2 correctly, but on a hard refresh likedPhotoIds was always initialized to an empty Set — so previously-liked photos rendered un-filled until the user opened the lightbox. Backend: gallery.js GET /:slug/photos now mounts resolveGuest and emits a per-viewer is_liked boolean per photo. Prefers req.guest.id when a verified guest token is present (per-person identity), falls back to the IP+UA hash that generateGuestIdentifier produces — same identity model galleryFeedback.js uses for /my-feedback. Skipped when feedback is hidden from guests. Frontend: Photo type gains optional is_liked. Each of the 7 grid layouts (Masonry / Grid / Justified / Timeline / Carousel / Mosaic / Premium) seeds its lifted likedPhotoIds Set from photos.filter(is_liked) on the first non-empty payload, gated by a seededRef so subsequent React Query refetches don't clobber in-session optimistic toggles. Mosaic uses photo.is_liked ?? false in its per-card useState initializer. GalleryPremium also drops the buggy `|| like_count > 0` fallback at line 521 that treated "anyone liked this" as "I liked it" — the per-viewer seed is now the correct source. GalleryStory had the same shape of bug in two places — same #590 fix: - Seed switched from like_count > 0 (global) to is_liked (per-viewer), with the same mount-only seededRef guard. - handleToggleFavorite now calls submitFeedback on EVERY click, not only when adding. The previous code skipped the unlike submit, so the UI removed the heart while the server kept the like row. --- backend/src/routes/gallery.js | 31 ++++++++++- .../gallery/layouts/CarouselGalleryLayout.tsx | 8 +++ .../gallery/layouts/GalleryPremiumLayout.tsx | 15 +++++- .../gallery/layouts/GalleryStoryLayout.tsx | 53 +++++++++---------- .../gallery/layouts/GridGalleryLayout.tsx | 8 +++ .../layouts/JustifiedGalleryLayout.tsx | 8 +++ .../gallery/layouts/MasonryGalleryLayout.tsx | 9 ++++ .../gallery/layouts/MosaicGalleryLayout.tsx | 4 +- .../gallery/layouts/TimelineGalleryLayout.tsx | 10 +++- frontend/src/types/index.ts | 6 +++ 10 files changed, 119 insertions(+), 33 deletions(-) diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index e155da0a..444712fc 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -7,6 +7,8 @@ const router = express.Router(); const watermarkService = require('../services/watermarkService'); const watermarkGeneratorService = require('../services/watermarkGeneratorService'); const { verifyGalleryAccess, isAdminPreview } = require('../middleware/gallery'); +const { resolveGuest } = require('../middleware/guestAuth'); +const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit'); const secureImageService = require('../services/secureImageService'); const logger = require('../utils/logger'); const { resolvePhotoFilePath } = require('../services/photoResolver'); @@ -211,7 +213,7 @@ router.get('/:slug/info', async (req, res) => { }); // Get all photos -router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { +router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) => { try { // Get filter and sort parameters from query const { filter, guest_id, sort = 'upload_date', order = 'desc' } = req.query; @@ -357,6 +359,29 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { commentCounts.forEach(c => { commentMap[c.photo_id] = parseInt(c.comment_count); }); + + // Per-viewer "is_liked" set (#590 follow-up). Hard refresh on the + // gallery grid used to reset every heart to empty because the lifted + // likedPhotoIds state started as a fresh Set on mount — even photos + // the viewer had actually liked. Surface a per-viewer flag so the + // frontend can seed correctly. Prefers req.guest.id when a verified + // guest token is present (per-person identity), falls back to the + // IP+UA hash that the original like was recorded under — same model + // the /my-feedback endpoint uses. Skipped when feedback is hidden + // from guests. + const likedPhotoIds = new Set(); + if (showFeedbackToGuests && photos.length > 0) { + const likeQuery = db('photo_feedback') + .where({ event_id: req.event.id, feedback_type: 'like' }) + .whereIn('photo_id', photos.map(p => p.id)); + if (req.guest?.id) { + likeQuery.where('guest_id', req.guest.id); + } else { + likeQuery.where('guest_identifier', generateGuestIdentifier(req)); + } + const likedRows = await likeQuery.select('photo_id'); + likedRows.forEach(row => likedPhotoIds.add(row.photo_id)); + } // Get actual categories used by photos in this event // This includes both global categories and event-specific ones @@ -524,6 +549,10 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { average_rating: showFeedbackToGuests ? (photo.average_rating || 0) : 0, comment_count: showFeedbackToGuests ? (commentMap[photo.id] || 0) : 0, like_count: showFeedbackToGuests ? (photo.like_count || 0) : 0, + // Per-viewer flag (#590 follow-up) — true when this viewer has + // an active like row for this photo, false otherwise. Lets the + // grid seed its lifted likedPhotoIds correctly on hard refresh. + is_liked: showFeedbackToGuests ? likedPhotoIds.has(photo.id) : false, favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0, // Visibility (only included for clients) ...(isClient ? { visibility: photo.visibility || 'visible' } : {}) diff --git a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx index 14a3360b..009ba744 100644 --- a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx @@ -69,6 +69,14 @@ export const CarouselGalleryLayout: React.FC = ({ const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const guestIdentity = useGuestIdentityOptional(); const [likedIds, setLikedIds] = useState>(new Set()); + // Seed from server is_liked on first non-empty payload (#590 follow-up). + // Mount-only so refetches don't clobber in-session optimistic toggles. + const likedSeededRef = useRef(false); + useEffect(() => { + if (likedSeededRef.current || photos.length === 0) return; + setLikedIds(new Set(photos.filter(p => p.is_liked).map(p => p.id))); + likedSeededRef.current = true; + }, [photos]); const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback); return ( diff --git a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx index 1cd4ef23..cebba08f 100644 --- a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { MasonryPhotoAlbum } from 'react-photo-album'; import 'react-photo-album/masonry.css'; import Lightbox from 'yet-another-react-lightbox'; @@ -199,6 +199,14 @@ export const GalleryPremiumLayout: React.FC = ({ const [lightboxIndex, setLightboxIndex] = useState(-1); const [activeCategory, setActiveCategory] = useState(null); const [likedPhotoIds, setLikedPhotoIds] = useState>(new Set()); + // Seed from server is_liked on first non-empty payload (#590 follow-up). + // Mount-only so refetches don't clobber in-session optimistic toggles. + const likedSeededRef = useRef(false); + useEffect(() => { + if (likedSeededRef.current || photos.length === 0) return; + setLikedPhotoIds(new Set(photos.filter(p => p.is_liked).map(p => p.id))); + likedSeededRef.current = true; + }, [photos]); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const guestIdentity = useGuestIdentityOptional(); const [showIdentityModal, setShowIdentityModal] = useState(false); @@ -518,7 +526,10 @@ export const GalleryPremiumLayout: React.FC = ({ }} isSelected={selectedPhotos.has(originalPhoto.id)} isSelectionMode={isSelectionMode} - isLiked={likedPhotoIds.has(originalPhoto.id) || (originalPhoto.like_count ?? 0) > 0} + // #590 follow-up: drop the `|| like_count > 0` fallback, + // which treated "anyone liked this" as "I liked it". The + // per-viewer is_liked seed above is the correct source. + isLiked={likedPhotoIds.has(originalPhoto.id)} slug={slug} allowDownloads={allowDownloads} protectionLevel={protectionLevel} diff --git a/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx b/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx index 8397ec25..ce0fc157 100644 --- a/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useCallback, useEffect } from 'react'; +import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { Search, Heart, Menu, LogOut } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -87,15 +87,16 @@ export const GalleryStoryLayout: React.FC = ({ return () => window.removeEventListener('scroll', handleScroll); }, []); - // Initialize favorites from photo like_counts + // Seed favorites from per-viewer is_liked on first non-empty payload + // (#590 follow-up). The previous code seeded from like_count > 0 which + // marked every photo with ANY likes as "favorited" for the current + // viewer — wrong. Also gated by a mount-only ref so refetches don't + // clobber the user's in-session toggles. + const favoritesSeededRef = useRef(false); useEffect(() => { - const initialFavorites = new Set(); - photos.forEach(photo => { - if ((photo.like_count ?? 0) > 0) { - initialFavorites.add(photo.id); - } - }); - setFavorites(initialFavorites); + if (favoritesSeededRef.current || photos.length === 0) return; + setFavorites(new Set(photos.filter(p => p.is_liked).map(p => p.id))); + favoritesSeededRef.current = true; }, [photos]); // Get hero photo @@ -138,27 +139,23 @@ export const GalleryStoryLayout: React.FC = ({ const handleToggleFavorite = useCallback(async (photoId: number) => { const newFavorites = new Set(favorites); - const isCurrentlyFavorite = newFavorites.has(photoId); - - if (isCurrentlyFavorite) { - newFavorites.delete(photoId); - } else { - newFavorites.add(photoId); - } + if (newFavorites.has(photoId)) newFavorites.delete(photoId); + else newFavorites.add(photoId); setFavorites(newFavorites); - // Only submit like if adding favorite - if (!isCurrentlyFavorite) { - try { - await feedbackService.submitFeedback(slug, String(photoId), { - feedback_type: 'like', - guest_name: savedIdentity?.name, - guest_email: savedIdentity?.email, - }); - onFeedbackChange?.(); - } catch (err) { - console.warn('Like submit failed', err); - } + // The server /feedback like endpoint is a toggle (#590) — fire on + // every click, not only when adding. The previous code skipped the + // submit on unlike, so the UI removed the heart but the server + // still had the like row. + try { + await feedbackService.submitFeedback(slug, String(photoId), { + feedback_type: 'like', + guest_name: savedIdentity?.name, + guest_email: savedIdentity?.email, + }); + onFeedbackChange?.(); + } catch (err) { + console.warn('Like submit failed', err); } }, [favorites, slug, savedIdentity, onFeedbackChange]); diff --git a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx index 2bbff5a8..0d6365a2 100644 --- a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx @@ -403,6 +403,14 @@ export const GridGalleryLayout: React.FC = ({ const [showIdentityModal, setShowIdentityModal] = React.useState(false); const [pendingAction, setPendingAction] = React.useState(null); const [likedPhotoIds, setLikedPhotoIds] = React.useState>(new Set()); + // Seed from server is_liked on first non-empty payload (#590 follow-up). + // Mount-only so refetches don't clobber in-session optimistic toggles. + const likedSeededRef = React.useRef(false); + React.useEffect(() => { + if (likedSeededRef.current || photos.length === 0) return; + setLikedPhotoIds(new Set(photos.filter(p => p.is_liked).map(p => p.id))); + likedSeededRef.current = true; + }, [photos]); const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null); const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4'; diff --git a/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx b/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx index 00b26ccb..a837c022 100644 --- a/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx @@ -543,6 +543,14 @@ export const JustifiedGalleryLayout: React.FC = ({ null ); const [likedPhotoIds, setLikedPhotoIds] = useState>(new Set()); + // Seed from server is_liked on first non-empty payload (#590 follow-up). + // Mount-only so refetches don't clobber in-session optimistic toggles. + const likedSeededRef = useRef(false); + useEffect(() => { + if (likedSeededRef.current || photos.length === 0) return; + setLikedPhotoIds(new Set(photos.filter(p => p.is_liked).map(p => p.id))); + likedSeededRef.current = true; + }, [photos]); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); // Track container width with ResizeObserver diff --git a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx index bad05802..7cea8d3c 100644 --- a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx @@ -281,6 +281,15 @@ export const MasonryGalleryLayout: React.FC = ({ // Optimistic "I liked this" state — lifted here so it survives re-renders // of individual MasonryPhoto components during layout reflow/resize. const [likedPhotoIds, setLikedPhotoIds] = useState>(new Set()); + // Seed from server is_liked on first non-empty photos payload (#590 + // follow-up). Mount-only: subsequent refetches don't clobber in-session + // optimistic toggles, only the first arrival of photos initializes. + const likedSeededRef = useRef(false); + useEffect(() => { + if (likedSeededRef.current || photos.length === 0) return; + setLikedPhotoIds(new Set(photos.filter(p => p.is_liked).map(p => p.id))); + likedSeededRef.current = true; + }, [photos]); const gallerySettings = theme.gallerySettings || {}; const gutter = gallerySettings.masonryGutter || 16; const mode = gallerySettings.masonryMode || 'columns'; diff --git a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx index c47643b1..1e2b06cf 100644 --- a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx @@ -55,7 +55,9 @@ const MosaicPhoto: React.FC = ({ const [pendingAction, setPendingAction] = React.useState(null); const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null); const guestIdentity = useGuestIdentityOptional(); - const [likedLocal, setLikedLocal] = React.useState(false); + // Seed from server is_liked (#590 follow-up). useState's initializer + // fires once on mount, so subsequent prop updates don't reseed. + const [likedLocal, setLikedLocal] = React.useState(photo.is_liked ?? false); const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment); // Calculate aspect ratio from photo dimensions (fallback to 1 if unknown) diff --git a/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx b/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx index de3a7ddd..ebaa361a 100644 --- a/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Download, Maximize2, Check, Calendar, Heart, MessageSquare } from 'lucide-react'; import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns'; import { useTheme } from '../../../contexts/ThemeContext'; @@ -24,6 +24,14 @@ export const TimelineGalleryLayout: React.FC = ({ }) => { const { theme } = useTheme(); const [likedIds, setLikedIds] = useState>(new Set()); + // Seed from server is_liked on first non-empty payload (#590 follow-up). + // Mount-only so refetches don't clobber in-session optimistic toggles. + const likedSeededRef = useRef(false); + useEffect(() => { + if (likedSeededRef.current || photos.length === 0) return; + setLikedIds(new Set(photos.filter(p => p.is_liked).map(p => p.id))); + likedSeededRef.current = true; + }, [photos]); const [showIdentityModal, setShowIdentityModal] = useState(false); const [pendingAction, setPendingAction] = useState(null); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 32f9553c..6d4b542b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -131,6 +131,12 @@ export interface Photo { total_ratings?: number; comment_count?: number; like_count?: number; + // Per-viewer flag (#590 follow-up). True when the requesting viewer has + // an active like row for this photo, false otherwise. Computed server-side + // by gallery.js using the same identity model as galleryFeedback.js + // (guest_id when a guest token is present, else IP+UA hash fallback). + // Used to seed the lifted likedPhotoIds Set in grid layouts on mount. + is_liked?: boolean; favorite_count?: number; }