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') && ( - {companyName} + // 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 && ( + {companyName} )} - {(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; }