fix(gallery): preserve per-viewer is_liked across hard refresh (#590 follow-up)
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.
This commit is contained in:
@@ -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' } : {})
|
||||
|
||||
@@ -69,6 +69,14 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [likedIds, setLikedIds] = useState<Set<number>>(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 (
|
||||
|
||||
@@ -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<GalleryPremiumLayoutProps> = ({
|
||||
const [lightboxIndex, setLightboxIndex] = useState(-1);
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(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<GalleryPremiumLayoutProps> = ({
|
||||
}}
|
||||
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}
|
||||
|
||||
@@ -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<GalleryStoryLayoutProps> = ({
|
||||
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<number>();
|
||||
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<GalleryStoryLayoutProps> = ({
|
||||
|
||||
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]);
|
||||
|
||||
|
||||
@@ -403,6 +403,14 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
|
||||
const [likedPhotoIds, setLikedPhotoIds] = React.useState<Set<number>>(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';
|
||||
|
||||
@@ -543,6 +543,14 @@ export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
|
||||
null
|
||||
);
|
||||
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(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
|
||||
|
||||
@@ -281,6 +281,15 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
// Optimistic "I liked this" state — lifted here so it survives re-renders
|
||||
// of individual MasonryPhoto components during layout reflow/resize.
|
||||
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(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';
|
||||
|
||||
@@ -55,7 +55,9 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(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)
|
||||
|
||||
@@ -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<BaseGalleryLayoutProps> = ({
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const [likedIds, setLikedIds] = useState<Set<number>>(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 | { type: 'like'; photoId: number }>(null);
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user