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/backend/src/routes/v1/__tests__/events.create.test.js b/backend/src/routes/v1/__tests__/events.create.test.js
index 6e0fb84f..a47b1daf 100644
--- a/backend/src/routes/v1/__tests__/events.create.test.js
+++ b/backend/src/routes/v1/__tests__/events.create.test.js
@@ -18,12 +18,17 @@
const request = require('supertest');
const express = require('express');
-const buildChain = ({ firstResult, insertResult, returningResult } = {}) => {
+const buildChain = ({ firstResult, insertResult, returningResult, selectResult } = {}) => {
const chain = {
where: jest.fn().mockReturnThis(),
+ whereIn: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
- select: jest.fn().mockReturnThis(),
+ // `select` resolves to an array so `await db(...).whereIn(...).select(...)`
+ // gives an iterable result (used by the branding-defaults probe added in
+ // #592 follow-up). Tests that don't need it leave selectResult undefined
+ // and get `[]`, which is a safe no-op for any caller that iterates.
+ select: jest.fn().mockResolvedValue(selectResult ?? []),
first: jest.fn().mockResolvedValue(firstResult),
insert: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]),
@@ -92,23 +97,28 @@ const BASE_BODY = {
require_password: false,
};
+// db() call sequence for BASE_BODY (no feedback / devtools provided,
+// require_password supplied so its probe is skipped, no customer_phone,
+// no slug collision):
+// 1. app_settings.where('event_default_feedback_enabled').first() (#550)
+// 2. app_settings.where('enable_devtools_protection').first() (#592)
+// 3. app_settings.whereIn([branding_logo_display_hero,...]).select(...) (#592 follow-up)
+// Then slug probe, events insert, optional feedback insert.
+const baseSettingsChains = () => [
+ buildChain({ firstResult: null }), // feedback default
+ buildChain({ firstResult: null }), // devtools default
+ buildChain({ selectResult: [] }), // branding whereIn → empty rows
+];
+
describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('persists color_theme to the events row when provided', async () => {
- // db() call sequence for this body (feedback_enabled omitted, no
- // customer_phone, no slug collision):
- // 1. app_settings.where('event_default_feedback_enabled').first()
- // 2. events.where({ slug }).first() ← uniqueness probe
- // 3. events.insert(...).returning('id')
- // No event_feedback_settings insert because the global setting
- // returns nothing (feedback stays off) — covered separately below.
- const settingChain = buildChain({ firstResult: null });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 42 }] });
- db.__setImplementations(settingChain, slugChain, insertChain);
+ db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
await request(buildApp())
.post('/events')
@@ -123,11 +133,9 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
});
it('accepts a JSON-encoded theme string and persists it verbatim', async () => {
- db.__setImplementations(
- buildChain({ firstResult: null }),
- buildChain({ firstResult: null }),
- buildChain({ returningResult: [{ id: 43 }] }),
- );
+ const slugChain = buildChain({ firstResult: null });
+ const insertChain = buildChain({ returningResult: [{ id: 43 }] });
+ db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
const customTheme = JSON.stringify({ primaryColor: '#ff0066' });
await request(buildApp())
@@ -135,26 +143,30 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
.send({ ...BASE_BODY, color_theme: customTheme })
.expect(201);
- const insertedRow = db.mock.results[2].value.insert.mock.calls[0][0];
+ const insertedRow = insertChain.insert.mock.calls[0][0];
expect(insertedRow.color_theme).toBe(customTheme);
});
it('creates event_feedback_settings row when feedback_enabled=true is sent', async () => {
- // 3 db() calls when feedback_enabled is sent explicitly (the
- // settings probe is skipped because feedbackEnabledInput !== undefined):
- // 1. slug probe, 2. events insert, 3. feedback insert
+ // feedback_enabled provided → feedback probe SKIPPED. Sequence:
+ // 1. devtools probe
+ // 2. branding probe (whereIn → select)
+ // 3. slug probe
+ // 4. events insert
+ // 5. event_feedback_settings insert
+ const devtoolsChain = buildChain({ firstResult: null });
+ const brandingChain = buildChain({ selectResult: [] });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 50 }] });
const feedbackInsertChain = buildChain();
- db.__setImplementations(slugChain, insertChain, feedbackInsertChain);
+ db.__setImplementations(devtoolsChain, brandingChain, slugChain, insertChain, feedbackInsertChain);
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, feedback_enabled: true })
.expect(201);
- // db('event_feedback_settings') is the 3rd invocation.
- expect(db).toHaveBeenNthCalledWith(3, 'event_feedback_settings');
+ expect(db).toHaveBeenNthCalledWith(5, 'event_feedback_settings');
const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0];
expect(feedbackRow).toMatchObject({ event_id: 50 });
@@ -172,39 +184,43 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
});
it('honours the event_default_feedback_enabled global when body omits feedback_enabled', async () => {
- // settings probe returns a serialized "true" — fallback should kick
- // in and the feedback row should still be written.
- const settingChain = buildChain({
+ // Feedback probe returns serialized "true" → fallback kicks in and
+ // the feedback insert runs. Sequence: feedback probe, devtools probe,
+ // branding probe, slug, insert, feedback insert (6 calls total).
+ const feedbackProbe = buildChain({
firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' },
});
+ const devtoolsChain = buildChain({ firstResult: null });
+ const brandingChain = buildChain({ selectResult: [] });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 51 }] });
const feedbackInsertChain = buildChain();
- db.__setImplementations(settingChain, slugChain, insertChain, feedbackInsertChain);
+ db.__setImplementations(
+ feedbackProbe, devtoolsChain, brandingChain, slugChain, insertChain, feedbackInsertChain
+ );
await request(buildApp())
.post('/events')
.send(BASE_BODY)
.expect(201);
- expect(db).toHaveBeenNthCalledWith(4, 'event_feedback_settings');
+ expect(db).toHaveBeenNthCalledWith(6, 'event_feedback_settings');
expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1);
});
it('does NOT create a feedback row when global setting is unset and body omits feedback_enabled', async () => {
- const settingChain = buildChain({ firstResult: null });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 52 }] });
- db.__setImplementations(settingChain, slugChain, insertChain);
+ db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
await request(buildApp())
.post('/events')
.send(BASE_BODY)
.expect(201);
- // Only 3 db() calls — the event_feedback_settings table is never
- // touched because feedback_enabled resolved to false.
- expect(db).toHaveBeenCalledTimes(3);
+ // 5 db() calls: feedback + devtools + branding probes, slug, insert.
+ // event_feedback_settings is never touched.
+ expect(db).toHaveBeenCalledTimes(5);
expect(db).not.toHaveBeenCalledWith('event_feedback_settings');
});
diff --git a/backend/src/routes/v1/events.js b/backend/src/routes/v1/events.js
index 3932009a..6684a6ea 100644
--- a/backend/src/routes/v1/events.js
+++ b/backend/src/routes/v1/events.js
@@ -86,11 +86,15 @@ const photoUpload = multer({
* customer_email: { type: string, format: email, nullable: true }
* customer_phone: { type: string, nullable: true, description: "Only persisted when the global phone-field setting is enabled." }
* admin_email: { type: string, format: email, nullable: true }
- * require_password: { type: boolean, default: true }
- * password: { type: string, nullable: true, description: "Required when require_password is true." }
+ * require_password: { type: boolean, nullable: true, description: "When omitted, falls back to the global event_default_require_password setting." }
+ * password: { type: string, nullable: true, description: "Required when require_password resolves to true." }
* expires_at: { type: string, format: date-time, nullable: true }
* color_theme: { type: string, nullable: true, description: "Preset name (e.g. 'default') or JSON-encoded ThemeConfig. Persisted as-is on the event row." }
* feedback_enabled: { type: boolean, nullable: true, description: "Enable guest feedback for this gallery. When omitted, falls back to the global event_default_feedback_enabled setting." }
+ * enable_devtools_protection: { type: boolean, nullable: true, description: "Block right-click / devtools shortcuts in the gallery. When omitted, falls back to the global enable_devtools_protection setting." }
+ * hero_logo_visible: { type: boolean, nullable: true, description: "Show event logo in the hero block. When omitted, falls back to the global branding_logo_display_hero setting." }
+ * hero_logo_size: { type: string, nullable: true, enum: [small, medium, large, xlarge], description: "Hero logo size. When omitted, falls back to the global branding_logo_size setting." }
+ * hero_logo_position: { type: string, nullable: true, enum: [top, center, bottom], description: "Hero logo position. Defaults to 'top' (not settings-backed — see migration 084)." }
* responses:
* 201:
* description: Event created
@@ -123,7 +127,11 @@ router.post(
body('password').optional({ nullable: true }).isString().isLength({ min: 6 }),
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('color_theme').optional({ nullable: true }).isString().trim(),
- body('feedback_enabled').optional().isBoolean()
+ body('feedback_enabled').optional().isBoolean(),
+ body('enable_devtools_protection').optional().isBoolean(),
+ body('hero_logo_visible').optional().isBoolean(),
+ body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
+ body('hero_logo_position').optional().isIn(['top', 'center', 'bottom'])
],
async (req, res) => {
try {
@@ -132,10 +140,16 @@ router.post(
const {
event_name, event_type, event_date,
customer_name = null, customer_email = null, customer_phone = null,
- admin_email = null, require_password = true, password,
+ admin_email = null,
+ require_password: requirePasswordInput,
+ password,
expires_at = null,
color_theme = null,
- feedback_enabled: feedbackEnabledInput
+ feedback_enabled: feedbackEnabledInput,
+ enable_devtools_protection: devtoolsInput,
+ hero_logo_visible: heroLogoVisibleInput,
+ hero_logo_size: heroLogoSizeInput,
+ hero_logo_position: heroLogoPositionInput
} = req.body;
// Issue #550 — mirror the admin POST path so API-created events
@@ -155,6 +169,60 @@ router.post(
}
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
+ // Issue #592 — same shape as the feedback fallback above. The
+ // events table column default is `true`, so without this an admin
+ // who disabled devtools detection globally still gets it ON for
+ // every API-created gallery. Mirrors adminEvents.js behaviour.
+ let devtoolsFallback = true;
+ if (devtoolsInput === undefined) {
+ const setting = await db('app_settings').where('setting_key', 'enable_devtools_protection').first();
+ if (setting) {
+ try {
+ const parsed = JSON.parse(setting.setting_value);
+ if (typeof parsed === 'boolean') devtoolsFallback = parsed;
+ } catch { /* keep true */ }
+ }
+ }
+ const enable_devtools_protection = parseBooleanInput(devtoolsInput, devtoolsFallback);
+
+ // Same shape as the feedback / devtools fallbacks: honour the global
+ // event_default_require_password toggle (#317). Without this an admin
+ // who disabled "require password by default" globally still got
+ // password-required galleries through the API.
+ let requirePasswordFallback = true;
+ if (requirePasswordInput === undefined) {
+ const setting = await db('app_settings').where('setting_key', 'event_default_require_password').first();
+ if (setting) {
+ try {
+ const parsed = JSON.parse(setting.setting_value);
+ if (typeof parsed === 'boolean') requirePasswordFallback = parsed;
+ } catch { /* keep true */ }
+ }
+ }
+ const require_password = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
+
+ // Branding inheritance (Feature 7) — mirror adminEvents.js
+ // getBrandingDefaults so API-created events inherit the global
+ // hero logo visibility + size. hero_logo_position is intentionally
+ // NOT settings-backed (see migration 084 / #357 — branding_logo_position
+ // is the *header bar*, a different concept than the hero block).
+ let heroLogoVisibleFallback = true;
+ let heroLogoSizeFallback = 'medium';
+ const brandingRows = await db('app_settings')
+ .whereIn('setting_key', ['branding_logo_display_hero', 'branding_logo_size'])
+ .select('setting_key', 'setting_value');
+ for (const row of brandingRows) {
+ let value = row.setting_value;
+ if (typeof value === 'string') {
+ try { value = JSON.parse(value); } catch { /* keep raw */ }
+ }
+ if (row.setting_key === 'branding_logo_display_hero') heroLogoVisibleFallback = value !== false;
+ if (row.setting_key === 'branding_logo_size' && value) heroLogoSizeFallback = value;
+ }
+ const hero_logo_visible = heroLogoVisibleInput !== undefined ? heroLogoVisibleInput : heroLogoVisibleFallback;
+ const hero_logo_size = heroLogoSizeInput || heroLogoSizeFallback;
+ const hero_logo_position = heroLogoPositionInput || 'top';
+
if (require_password && (!password || password.length < 6)) {
return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' });
}
@@ -203,6 +271,13 @@ router.post(
// admin UI snaps the theme picker to GALLERY_THEME_PRESETS.default
// and saving overwrites whatever theme was inherited visually.
color_theme,
+ // Issue #592 — write the resolved devtools setting (input value
+ // or global fallback) so the column default doesn't shadow it.
+ enable_devtools_protection: formatBoolean(enable_devtools_protection),
+ // Branding inheritance — resolved value from body or app_settings.
+ hero_logo_visible: formatBoolean(hero_logo_visible),
+ hero_logo_size,
+ hero_logo_position,
...(customer_name ? { customer_name } : {}),
...(customer_email ? { customer_email } : {}),
...(persistPhone ? { customer_phone: persistPhone } : {})
diff --git a/frontend/index.html b/frontend/index.html
index 9af5be8f..0e9b724a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -39,9 +39,9 @@
+
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index 9a16f008..1c8dc721 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -24,6 +24,15 @@ server {
client_max_body_size 1G;
client_body_timeout 300s;
+ # Defensive header buffer bump (#591). Default `4 8k` is too tight when
+ # an outer Cloudflare / corp-proxy sits in front and injects long
+ # Set-Cookie / X-Forwarded-* headers, or when a power-user accumulates
+ # many per-gallery `gallery_token_` cookies over the 24h maxAge
+ # in tokenUtils.js. Either way users hit "400 Request Header Or Cookie
+ # Too Large" and clearing cookies is the only fix. 4×32k is cheap RAM
+ # and matches what most reverse proxies already do upstream.
+ large_client_header_buffers 4 32k;
+
# Gzip compression
gzip on;
gzip_vary on;
diff --git a/frontend/public/bootstrap.js b/frontend/public/bootstrap.js
new file mode 100644
index 00000000..f40e14b2
--- /dev/null
+++ b/frontend/public/bootstrap.js
@@ -0,0 +1,32 @@
+/*
+ * Pre-React theme bootstrap (#358).
+ *
+ * Loaded as an external script (rather than inline) so a strict CSP
+ * with no 'unsafe-inline' / hash / nonce — like the one Caddy puts in
+ * front of demo.picpeak.app — does not block it (#564).
+ *
+ * Reads the per-gallery cached background written by ThemeContext on
+ * the previous visit and applies it before React mounts, so revisits
+ * land on the right colour from the first frame. The OS-preference
+ * default is already handled by the @media CSS in index.html for
+ * first-visit / cache-miss callers.
+ *
+ * Placed in /public so vite copies it to /bootstrap.js at build time
+ * (same pipeline as /favicon-32x32.png). Kept in without
+ * defer/async so it runs before paints.
+ */
+(function () {
+ try {
+ var m = location.pathname.match(/\/gallery\/([^\/?#]+)/);
+ var bg = null;
+ if (m && m[1]) {
+ bg = localStorage.getItem('gallery-theme-bg-' + decodeURIComponent(m[1]));
+ }
+ if (bg) {
+ var root = document.documentElement;
+ root.style.backgroundColor = bg;
+ document.body && (document.body.style.backgroundColor = bg);
+ root.style.setProperty('--color-background', bg);
+ }
+ } catch (e) { /* never block render on a cache miss */ }
+})();
diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx
index 7217f825..ff3efaaa 100644
--- a/frontend/src/components/admin/AdminHeader.tsx
+++ b/frontend/src/components/admin/AdminHeader.tsx
@@ -48,13 +48,27 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => {
// Renders the logo + wordmark block per the current logo_display_mode.
// Re-used in left / center / right slots below so all three positions
// produce visually identical brand chrome.
+ const showLogo = !logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text');
+ const showText = logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text';
+ // On (
-
- {!logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && (
-

+ // min-w-0 + truncate on the name span so long company names shrink
+ // within the left cluster instead of pushing into the right-side
+ // action buttons on narrow mobile widths (#523 regression).
+
+ {showLogo && (
+

)}
- {(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && (
-
{companyName}
+ {showText && (
+
{companyName}
)}
);
diff --git a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx
index f20d040e..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 (
@@ -158,7 +166,13 @@ export const CarouselGalleryLayout: React.FC = ({
} 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 +185,13 @@ export const CarouselGalleryLayout: React.FC = ({
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',
diff --git a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx
index 9bc02f12..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);
@@ -260,9 +268,11 @@ export const GalleryPremiumLayout: React.FC = ({
} 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 +292,11 @@ export const GalleryPremiumLayout: React.FC = ({
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 +317,14 @@ export const GalleryPremiumLayout: React.FC = ({
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;
});
@@ -510,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 4a89c1f2..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';
@@ -443,9 +451,12 @@ export const GridGalleryLayout: React.FC = ({
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 +493,12 @@ export const GridGalleryLayout: React.FC = ({
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;
});
}
diff --git a/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx b/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx
index f34d4f36..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
@@ -763,9 +771,12 @@ export const JustifiedGalleryLayout: React.FC = ({
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 +799,12 @@ export const JustifiedGalleryLayout: React.FC = ({
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;
});
}
diff --git a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx
index 9cd5b0db..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';
@@ -832,9 +841,13 @@ export const MasonryGalleryLayout: React.FC = ({
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;
});
}}
diff --git a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx
index 2335afe2..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)
@@ -116,7 +118,8 @@ const MosaicPhoto: React.FC = ({
} 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 +132,8 @@ const MosaicPhoto: React.FC = ({
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',
diff --git a/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx b/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx
index 279fdbb7..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);
@@ -155,7 +163,13 @@ export const TimelineGalleryLayout: React.FC = ({
} 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 +182,13 @@ export const TimelineGalleryLayout: React.FC = ({
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',
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;
}