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; }