fix(gallery): toggle (not add) the local liked set on click (#590)

The /feedback like endpoint is a server-side toggle — the same one
the lightbox uses. Every grid layout's optimistic-UI setter only
ever did next.add(photoId), so click 2 on a liked tile fired a
server unlike but kept the heart filled in the UI.

Switch each setter to toggle (delete if present, else add). Covers
Masonry (default), Grid, Justified, Timeline, Carousel, Mosaic, and
Premium layouts — including their identity-modal callback paths for
shape consistency. Lightbox toggle is unchanged (already correct).
This commit is contained in:
Paul Nothaft
2026-05-31 22:35:15 +02:00
parent e7cf834325
commit d292b9fa10
7 changed files with 63 additions and 16 deletions
@@ -158,7 +158,13 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
} catch {
return;
}
setLikedIds(prev => new Set(prev).add(currentPhoto.id));
// Toggle — server /feedback like is a toggle (#590).
setLikedIds(prev => {
const next = new Set(prev);
if (next.has(currentPhoto.id)) next.delete(currentPhoto.id);
else next.add(currentPhoto.id);
return next;
});
try {
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
feedback_type: 'like',
@@ -171,7 +177,13 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
setShowIdentityModal(true);
return;
}
setLikedIds(prev => new Set(prev).add(currentPhoto.id));
// Toggle — server /feedback like is a toggle (#590).
setLikedIds(prev => {
const next = new Set(prev);
if (next.has(currentPhoto.id)) next.delete(currentPhoto.id);
else next.add(currentPhoto.id);
return next;
});
try {
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
feedback_type: 'like',
@@ -260,9 +260,11 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
} catch {
return;
}
// Toggle — server /feedback like is a toggle (#590).
setLikedPhotoIds(prev => {
const next = new Set(prev);
next.add(photo.id);
if (next.has(photo.id)) next.delete(photo.id);
else next.add(photo.id);
return next;
});
try {
@@ -282,10 +284,11 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
return;
}
// Optimistic update
// Optimistic update — toggle, not add (#590).
setLikedPhotoIds(prev => {
const next = new Set(prev);
next.add(photo.id);
if (next.has(photo.id)) next.delete(photo.id);
else next.add(photo.id);
return next;
});
@@ -306,9 +309,14 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
setShowIdentityModal(false);
if (pendingLikePhotoId) {
// Toggle — server /feedback like is a toggle (#590). The identity
// modal only fires the first time per session, so the user is
// intentionally liking a not-yet-liked photo here, but keep the
// setter shape consistent with the other paths.
setLikedPhotoIds(prev => {
const next = new Set(prev);
next.add(pendingLikePhotoId);
if (next.has(pendingLikePhotoId)) next.delete(pendingLikePhotoId);
else next.add(pendingLikePhotoId);
return next;
});
@@ -443,9 +443,12 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
onFeedbackChange={onFeedbackChange}
liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => {
// Toggle, not add — like endpoint toggles server-side,
// so the optimistic UI has to follow suit on click 2 (#590).
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(photo.id);
if (next.has(photo.id)) next.delete(photo.id);
else next.add(photo.id);
return next;
});
}}
@@ -482,11 +485,12 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
guest_name: name,
guest_email: email,
});
// Immediately reflect like UI
// Immediately reflect like UI — toggle for consistency (#590).
if (pendingAction.type === 'like') {
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(pendingAction.photoId);
if (next.has(pendingAction.photoId)) next.delete(pendingAction.photoId);
else next.add(pendingAction.photoId);
return next;
});
}
@@ -763,9 +763,12 @@ export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
onFeedbackChange={onFeedbackChange}
liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => {
// Toggle, not add — like endpoint toggles server-side,
// so the optimistic UI has to follow suit on click 2 (#590).
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(photo.id);
if (next.has(photo.id)) next.delete(photo.id);
else next.add(photo.id);
return next;
});
}}
@@ -788,10 +791,12 @@ export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
guest_name: name,
guest_email: email,
});
// Toggle for consistency (#590).
if (pendingAction.type === 'like') {
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(pendingAction.photoId);
if (next.has(pendingAction.photoId)) next.delete(pendingAction.photoId);
else next.add(pendingAction.photoId);
return next;
});
}
@@ -832,9 +832,13 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
columnWidth={columnWidth}
liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => {
// Toggle, not add — the /feedback like endpoint toggles
// server-side, so click 2 on a liked photo unlikes it;
// the optimistic UI must follow suit (#590).
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(photo.id);
if (next.has(photo.id)) next.delete(photo.id);
else next.add(photo.id);
return next;
});
}}
@@ -116,7 +116,8 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
} catch {
return;
}
setLikedLocal(true);
// Toggle — server /feedback like is a toggle (#590).
setLikedLocal(prev => !prev);
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
@@ -129,7 +130,8 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
setShowIdentityModal(true);
return;
}
setLikedLocal(true);
// Toggle — server /feedback like is a toggle (#590).
setLikedLocal(prev => !prev);
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
@@ -155,7 +155,13 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
} catch {
return;
}
setLikedIds(prev => new Set(prev).add(photo.id));
// Toggle — server /feedback like is a toggle (#590).
setLikedIds(prev => {
const next = new Set(prev);
if (next.has(photo.id)) next.delete(photo.id);
else next.add(photo.id);
return next;
});
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
@@ -168,7 +174,13 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
setShowIdentityModal(true);
return;
}
setLikedIds(prev => new Set(prev).add(photo.id));
// Toggle — server /feedback like is a toggle (#590).
setLikedIds(prev => {
const next = new Set(prev);
if (next.has(photo.id)) next.delete(photo.id);
else next.add(photo.id);
return next;
});
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',