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:
Paul Nothaft
2026-05-31 22:47:16 +02:00
parent 2d44b1ab2d
commit 791e9974eb
10 changed files with 119 additions and 33 deletions
@@ -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);
+6
View File
@@ -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;
}