Merge pull request #594 from the-luap/fix/bugs-batch-523-564-590-591-592

fix(bug-batch): #523 #564 #590 #591 #592
This commit is contained in:
Paul Nothaft
2026-05-31 23:09:49 +02:00
committed by GitHub
16 changed files with 379 additions and 120 deletions
+30 -1
View File
@@ -7,6 +7,8 @@ const router = express.Router();
const watermarkService = require('../services/watermarkService'); const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService'); const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const { verifyGalleryAccess, isAdminPreview } = require('../middleware/gallery'); const { verifyGalleryAccess, isAdminPreview } = require('../middleware/gallery');
const { resolveGuest } = require('../middleware/guestAuth');
const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const secureImageService = require('../services/secureImageService'); const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver'); const { resolvePhotoFilePath } = require('../services/photoResolver');
@@ -211,7 +213,7 @@ router.get('/:slug/info', async (req, res) => {
}); });
// Get all photos // Get all photos
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) => {
try { try {
// Get filter and sort parameters from query // Get filter and sort parameters from query
const { filter, guest_id, sort = 'upload_date', order = 'desc' } = req.query; const { filter, guest_id, sort = 'upload_date', order = 'desc' } = req.query;
@@ -358,6 +360,29 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
commentMap[c.photo_id] = parseInt(c.comment_count); 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 // Get actual categories used by photos in this event
// This includes both global categories and event-specific ones // This includes both global categories and event-specific ones
const usedCategoryIds = await db('photos') const usedCategoryIds = await db('photos')
@@ -524,6 +549,10 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
average_rating: showFeedbackToGuests ? (photo.average_rating || 0) : 0, average_rating: showFeedbackToGuests ? (photo.average_rating || 0) : 0,
comment_count: showFeedbackToGuests ? (commentMap[photo.id] || 0) : 0, comment_count: showFeedbackToGuests ? (commentMap[photo.id] || 0) : 0,
like_count: showFeedbackToGuests ? (photo.like_count || 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, favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0,
// Visibility (only included for clients) // Visibility (only included for clients)
...(isClient ? { visibility: photo.visibility || 'visible' } : {}) ...(isClient ? { visibility: photo.visibility || 'visible' } : {})
@@ -18,12 +18,17 @@
const request = require('supertest'); const request = require('supertest');
const express = require('express'); const express = require('express');
const buildChain = ({ firstResult, insertResult, returningResult } = {}) => { const buildChain = ({ firstResult, insertResult, returningResult, selectResult } = {}) => {
const chain = { const chain = {
where: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(),
whereIn: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(),
orWhere: 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), first: jest.fn().mockResolvedValue(firstResult),
insert: jest.fn().mockReturnThis(), insert: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]), returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]),
@@ -92,23 +97,28 @@ const BASE_BODY = {
require_password: false, 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)', () => { describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
it('persists color_theme to the events row when provided', async () => { 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 slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 42 }] }); const insertChain = buildChain({ returningResult: [{ id: 42 }] });
db.__setImplementations(settingChain, slugChain, insertChain); db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
await request(buildApp()) await request(buildApp())
.post('/events') .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 () => { it('accepts a JSON-encoded theme string and persists it verbatim', async () => {
db.__setImplementations( const slugChain = buildChain({ firstResult: null });
buildChain({ firstResult: null }), const insertChain = buildChain({ returningResult: [{ id: 43 }] });
buildChain({ firstResult: null }), db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
buildChain({ returningResult: [{ id: 43 }] }),
);
const customTheme = JSON.stringify({ primaryColor: '#ff0066' }); const customTheme = JSON.stringify({ primaryColor: '#ff0066' });
await request(buildApp()) await request(buildApp())
@@ -135,26 +143,30 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
.send({ ...BASE_BODY, color_theme: customTheme }) .send({ ...BASE_BODY, color_theme: customTheme })
.expect(201); .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); expect(insertedRow.color_theme).toBe(customTheme);
}); });
it('creates event_feedback_settings row when feedback_enabled=true is sent', async () => { it('creates event_feedback_settings row when feedback_enabled=true is sent', async () => {
// 3 db() calls when feedback_enabled is sent explicitly (the // feedback_enabled provided → feedback probe SKIPPED. Sequence:
// settings probe is skipped because feedbackEnabledInput !== undefined): // 1. devtools probe
// 1. slug probe, 2. events insert, 3. feedback insert // 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 slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 50 }] }); const insertChain = buildChain({ returningResult: [{ id: 50 }] });
const feedbackInsertChain = buildChain(); const feedbackInsertChain = buildChain();
db.__setImplementations(slugChain, insertChain, feedbackInsertChain); db.__setImplementations(devtoolsChain, brandingChain, slugChain, insertChain, feedbackInsertChain);
await request(buildApp()) await request(buildApp())
.post('/events') .post('/events')
.send({ ...BASE_BODY, feedback_enabled: true }) .send({ ...BASE_BODY, feedback_enabled: true })
.expect(201); .expect(201);
// db('event_feedback_settings') is the 3rd invocation. expect(db).toHaveBeenNthCalledWith(5, 'event_feedback_settings');
expect(db).toHaveBeenNthCalledWith(3, 'event_feedback_settings');
const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0]; const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0];
expect(feedbackRow).toMatchObject({ event_id: 50 }); 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 () => { it('honours the event_default_feedback_enabled global when body omits feedback_enabled', async () => {
// settings probe returns a serialized "true" fallback should kick // Feedback probe returns serialized "true" fallback kicks in and
// in and the feedback row should still be written. // the feedback insert runs. Sequence: feedback probe, devtools probe,
const settingChain = buildChain({ // branding probe, slug, insert, feedback insert (6 calls total).
const feedbackProbe = buildChain({
firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' }, 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 slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 51 }] }); const insertChain = buildChain({ returningResult: [{ id: 51 }] });
const feedbackInsertChain = buildChain(); const feedbackInsertChain = buildChain();
db.__setImplementations(settingChain, slugChain, insertChain, feedbackInsertChain); db.__setImplementations(
feedbackProbe, devtoolsChain, brandingChain, slugChain, insertChain, feedbackInsertChain
);
await request(buildApp()) await request(buildApp())
.post('/events') .post('/events')
.send(BASE_BODY) .send(BASE_BODY)
.expect(201); .expect(201);
expect(db).toHaveBeenNthCalledWith(4, 'event_feedback_settings'); expect(db).toHaveBeenNthCalledWith(6, 'event_feedback_settings');
expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1); expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1);
}); });
it('does NOT create a feedback row when global setting is unset and body omits feedback_enabled', async () => { 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 slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 52 }] }); const insertChain = buildChain({ returningResult: [{ id: 52 }] });
db.__setImplementations(settingChain, slugChain, insertChain); db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
await request(buildApp()) await request(buildApp())
.post('/events') .post('/events')
.send(BASE_BODY) .send(BASE_BODY)
.expect(201); .expect(201);
// Only 3 db() calls — the event_feedback_settings table is never // 5 db() calls: feedback + devtools + branding probes, slug, insert.
// touched because feedback_enabled resolved to false. // event_feedback_settings is never touched.
expect(db).toHaveBeenCalledTimes(3); expect(db).toHaveBeenCalledTimes(5);
expect(db).not.toHaveBeenCalledWith('event_feedback_settings'); expect(db).not.toHaveBeenCalledWith('event_feedback_settings');
}); });
+80 -5
View File
@@ -86,11 +86,15 @@ const photoUpload = multer({
* customer_email: { type: string, format: email, nullable: true } * 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." } * 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 } * admin_email: { type: string, format: email, nullable: true }
* require_password: { type: boolean, default: 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 is true." } * password: { type: string, nullable: true, description: "Required when require_password resolves to true." }
* expires_at: { type: string, format: date-time, nullable: 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." } * 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." } * 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: * responses:
* 201: * 201:
* description: Event created * description: Event created
@@ -123,7 +127,11 @@ router.post(
body('password').optional({ nullable: true }).isString().isLength({ min: 6 }), body('password').optional({ nullable: true }).isString().isLength({ min: 6 }),
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(), body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('color_theme').optional({ nullable: true }).isString().trim(), 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) => { async (req, res) => {
try { try {
@@ -132,10 +140,16 @@ router.post(
const { const {
event_name, event_type, event_date, event_name, event_type, event_date,
customer_name = null, customer_email = null, customer_phone = null, 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, expires_at = null,
color_theme = 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; } = req.body;
// Issue #550 — mirror the admin POST path so API-created events // Issue #550 — mirror the admin POST path so API-created events
@@ -155,6 +169,60 @@ router.post(
} }
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback); 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)) { if (require_password && (!password || password.length < 6)) {
return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' }); 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 // admin UI snaps the theme picker to GALLERY_THEME_PRESETS.default
// and saving overwrites whatever theme was inherited visually. // and saving overwrites whatever theme was inherited visually.
color_theme, 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_name ? { customer_name } : {}),
...(customer_email ? { customer_email } : {}), ...(customer_email ? { customer_email } : {}),
...(persistPhone ? { customer_phone: persistPhone } : {}) ...(persistPhone ? { customer_phone: persistPhone } : {})
+8 -28
View File
@@ -39,9 +39,9 @@
<meta name="twitter:description" content="${BRAND_DESCRIPTION}" /> <meta name="twitter:description" content="${BRAND_DESCRIPTION}" />
<!-- Pre-React theme bootstrap (#358). <!-- Pre-React theme bootstrap (#358).
The browser may paint the very first frame before our inline The browser may paint the very first frame before the bootstrap
<script> below runs, so we set OS-preference defaults via CSS <script> below fetches and runs, so we set OS-preference defaults
here in <head> — that gets applied before any paint. The via CSS here in <head> — that gets applied before any paint. The
script then layers a per-gallery cache hit on top when one is script then layers a per-gallery cache hit on top when one is
available. Without this CSS, the very first frame on first- available. Without this CSS, the very first frame on first-
visit dark-OS devices flashed white briefly (see Rekoo-PS's visit dark-OS devices flashed white briefly (see Rekoo-PS's
@@ -56,31 +56,11 @@
html { transition: background-color 200ms ease; } html { transition: background-color 200ms ease; }
</style> </style>
<script> <!-- Pre-React theme bootstrap (#358). External rather than inline so a
/* strict CSP without 'unsafe-inline' / hash / nonce — like the one
* Pre-React theme bootstrap (#358). Caddy puts in front of demo.picpeak.app — doesn't block it (#564).
* No defer/async: must run before <body> paints. -->
* The CSS @media block above handles the OS-preference default <script src="/bootstrap.js"></script>
* before paint. This script then applies a per-gallery cached
* background (written by ThemeContext on the previous visit) so
* revisits land on the exact theme background from frame one.
*/
(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 */ }
})();
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+9
View File
@@ -24,6 +24,15 @@ server {
client_max_body_size 1G; client_max_body_size 1G;
client_body_timeout 300s; 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_<slug>` 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 compression
gzip on; gzip on;
gzip_vary on; gzip_vary on;
+32
View File
@@ -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 <head> without
* defer/async so it runs before <body> 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 */ }
})();
+19 -5
View File
@@ -48,13 +48,27 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
// Renders the logo + wordmark block per the current logo_display_mode. // Renders the logo + wordmark block per the current logo_display_mode.
// Re-used in left / center / right slots below so all three positions // Re-used in left / center / right slots below so all three positions
// produce visually identical brand chrome. // 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 <sm the wordmark hides when the logo carries the brand identity
// (logo_and_text). Same pattern LanguageSelector uses for its language
// name (#527). Without this, even with truncate, a phone-width admin
// shows things like "Ar..." after the logo image — readable but ugly,
// and on accounts whose company name lets the text reach the right
// cluster it overlaps the LanguageSelector button (#523 follow-up,
// Rekoo-PS's "Arkan Studio" screenshot in v3.59.0-beta.0). text_only
// mode keeps the wordmark on every width — nothing else would render.
const wordmarkVisibilityClass = showLogo ? 'hidden sm:inline' : 'inline';
const renderBrandBlock = () => ( const renderBrandBlock = () => (
<div className="flex items-center gap-2"> // min-w-0 + truncate on the name span so long company names shrink
{!logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && ( // within the left cluster instead of pushing into the right-side
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain" /> // action buttons on narrow mobile widths (#523 regression).
<div className="flex items-center gap-2 min-w-0">
{showLogo && (
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain flex-shrink-0" />
)} )}
{(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && ( {showText && (
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span> <span className={`${wordmarkVisibilityClass} text-xl sm:text-2xl truncate`} style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
)} )}
</div> </div>
); );
@@ -69,6 +69,14 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional(); const guestIdentity = useGuestIdentityOptional();
const [likedIds, setLikedIds] = useState<Set<number>>(new Set()); 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); const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
return ( return (
@@ -158,7 +166,13 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
} catch { } catch {
return; 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 { try {
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), { await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
feedback_type: 'like', feedback_type: 'like',
@@ -171,7 +185,13 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
setShowIdentityModal(true); setShowIdentityModal(true);
return; 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 { try {
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), { await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
feedback_type: 'like', feedback_type: 'like',
@@ -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 { MasonryPhotoAlbum } from 'react-photo-album';
import 'react-photo-album/masonry.css'; import 'react-photo-album/masonry.css';
import Lightbox from 'yet-another-react-lightbox'; import Lightbox from 'yet-another-react-lightbox';
@@ -199,6 +199,14 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
const [lightboxIndex, setLightboxIndex] = useState(-1); const [lightboxIndex, setLightboxIndex] = useState(-1);
const [activeCategory, setActiveCategory] = useState<string | null>(null); const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set()); 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 [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional(); const guestIdentity = useGuestIdentityOptional();
const [showIdentityModal, setShowIdentityModal] = useState(false); const [showIdentityModal, setShowIdentityModal] = useState(false);
@@ -260,9 +268,11 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
} catch { } catch {
return; return;
} }
// Toggle — server /feedback like is a toggle (#590).
setLikedPhotoIds(prev => { setLikedPhotoIds(prev => {
const next = new Set(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; return next;
}); });
try { try {
@@ -282,10 +292,11 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
return; return;
} }
// Optimistic update // Optimistic update — toggle, not add (#590).
setLikedPhotoIds(prev => { setLikedPhotoIds(prev => {
const next = new Set(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; return next;
}); });
@@ -306,9 +317,14 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
setShowIdentityModal(false); setShowIdentityModal(false);
if (pendingLikePhotoId) { 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 => { setLikedPhotoIds(prev => {
const next = new Set(prev); const next = new Set(prev);
next.add(pendingLikePhotoId); if (next.has(pendingLikePhotoId)) next.delete(pendingLikePhotoId);
else next.add(pendingLikePhotoId);
return next; return next;
}); });
@@ -510,7 +526,10 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
}} }}
isSelected={selectedPhotos.has(originalPhoto.id)} isSelected={selectedPhotos.has(originalPhoto.id)}
isSelectionMode={isSelectionMode} 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} slug={slug}
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
protectionLevel={protectionLevel} 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 { Search, Heart, Menu, LogOut } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -87,15 +87,16 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
return () => window.removeEventListener('scroll', handleScroll); 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(() => { useEffect(() => {
const initialFavorites = new Set<number>(); if (favoritesSeededRef.current || photos.length === 0) return;
photos.forEach(photo => { setFavorites(new Set(photos.filter(p => p.is_liked).map(p => p.id)));
if ((photo.like_count ?? 0) > 0) { favoritesSeededRef.current = true;
initialFavorites.add(photo.id);
}
});
setFavorites(initialFavorites);
}, [photos]); }, [photos]);
// Get hero photo // Get hero photo
@@ -138,17 +139,14 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
const handleToggleFavorite = useCallback(async (photoId: number) => { const handleToggleFavorite = useCallback(async (photoId: number) => {
const newFavorites = new Set(favorites); const newFavorites = new Set(favorites);
const isCurrentlyFavorite = newFavorites.has(photoId); if (newFavorites.has(photoId)) newFavorites.delete(photoId);
else newFavorites.add(photoId);
if (isCurrentlyFavorite) {
newFavorites.delete(photoId);
} else {
newFavorites.add(photoId);
}
setFavorites(newFavorites); setFavorites(newFavorites);
// Only submit like if adding favorite // The server /feedback like endpoint is a toggle (#590) — fire on
if (!isCurrentlyFavorite) { // 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 { try {
await feedbackService.submitFeedback(slug, String(photoId), { await feedbackService.submitFeedback(slug, String(photoId), {
feedback_type: 'like', feedback_type: 'like',
@@ -159,7 +157,6 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
} catch (err) { } catch (err) {
console.warn('Like submit failed', err); console.warn('Like submit failed', err);
} }
}
}, [favorites, slug, savedIdentity, onFeedbackChange]); }, [favorites, slug, savedIdentity, onFeedbackChange]);
const handleOpenFeedback = useCallback((photo: Photo) => { const handleOpenFeedback = useCallback((photo: Photo) => {
@@ -403,6 +403,14 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const [showIdentityModal, setShowIdentityModal] = React.useState(false); const [showIdentityModal, setShowIdentityModal] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null); const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
const [likedPhotoIds, setLikedPhotoIds] = React.useState<Set<number>>(new Set()); 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 [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4'; const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
@@ -443,9 +451,12 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
onFeedbackChange={onFeedbackChange} onFeedbackChange={onFeedbackChange}
liked={likedPhotoIds.has(photo.id)} liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => { onLikeSuccess={() => {
// Toggle, not add — like endpoint toggles server-side,
// so the optimistic UI has to follow suit on click 2 (#590).
setLikedPhotoIds((prev) => { setLikedPhotoIds((prev) => {
const next = new Set(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; return next;
}); });
}} }}
@@ -482,11 +493,12 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
guest_name: name, guest_name: name,
guest_email: email, guest_email: email,
}); });
// Immediately reflect like UI // Immediately reflect like UI — toggle for consistency (#590).
if (pendingAction.type === 'like') { if (pendingAction.type === 'like') {
setLikedPhotoIds((prev) => { setLikedPhotoIds((prev) => {
const next = new Set(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; return next;
}); });
} }
@@ -543,6 +543,14 @@ export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
null null
); );
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set()); 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 [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
// Track container width with ResizeObserver // Track container width with ResizeObserver
@@ -763,9 +771,12 @@ export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
onFeedbackChange={onFeedbackChange} onFeedbackChange={onFeedbackChange}
liked={likedPhotoIds.has(photo.id)} liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => { onLikeSuccess={() => {
// Toggle, not add — like endpoint toggles server-side,
// so the optimistic UI has to follow suit on click 2 (#590).
setLikedPhotoIds((prev) => { setLikedPhotoIds((prev) => {
const next = new Set(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; return next;
}); });
}} }}
@@ -788,10 +799,12 @@ export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
guest_name: name, guest_name: name,
guest_email: email, guest_email: email,
}); });
// Toggle for consistency (#590).
if (pendingAction.type === 'like') { if (pendingAction.type === 'like') {
setLikedPhotoIds((prev) => { setLikedPhotoIds((prev) => {
const next = new Set(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; return next;
}); });
} }
@@ -281,6 +281,15 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
// Optimistic "I liked this" state — lifted here so it survives re-renders // Optimistic "I liked this" state — lifted here so it survives re-renders
// of individual MasonryPhoto components during layout reflow/resize. // of individual MasonryPhoto components during layout reflow/resize.
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set()); 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 gallerySettings = theme.gallerySettings || {};
const gutter = gallerySettings.masonryGutter || 16; const gutter = gallerySettings.masonryGutter || 16;
const mode = gallerySettings.masonryMode || 'columns'; const mode = gallerySettings.masonryMode || 'columns';
@@ -832,9 +841,13 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
columnWidth={columnWidth} columnWidth={columnWidth}
liked={likedPhotoIds.has(photo.id)} liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => { 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) => { setLikedPhotoIds((prev) => {
const next = new Set(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; return next;
}); });
}} }}
@@ -55,7 +55,9 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null); const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null); const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional(); 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); const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment);
// Calculate aspect ratio from photo dimensions (fallback to 1 if unknown) // Calculate aspect ratio from photo dimensions (fallback to 1 if unknown)
@@ -116,7 +118,8 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
} catch { } catch {
return; return;
} }
setLikedLocal(true); // Toggle — server /feedback like is a toggle (#590).
setLikedLocal(prev => !prev);
try { try {
await feedbackService.submitFeedback(slug!, String(photo.id), { await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like', feedback_type: 'like',
@@ -129,7 +132,8 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
setShowIdentityModal(true); setShowIdentityModal(true);
return; return;
} }
setLikedLocal(true); // Toggle — server /feedback like is a toggle (#590).
setLikedLocal(prev => !prev);
try { try {
await feedbackService.submitFeedback(slug!, String(photo.id), { await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like', feedback_type: 'like',
@@ -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 { Download, Maximize2, Check, Calendar, Heart, MessageSquare } from 'lucide-react';
import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns'; import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
@@ -24,6 +24,14 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}) => { }) => {
const { theme } = useTheme(); const { theme } = useTheme();
const [likedIds, setLikedIds] = useState<Set<number>>(new Set()); 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 [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null); const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
@@ -155,7 +163,13 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
} catch { } catch {
return; 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 { try {
await feedbackService.submitFeedback(slug!, String(photo.id), { await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like', feedback_type: 'like',
@@ -168,7 +182,13 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
setShowIdentityModal(true); setShowIdentityModal(true);
return; 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 { try {
await feedbackService.submitFeedback(slug!, String(photo.id), { await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like', feedback_type: 'like',
+6
View File
@@ -131,6 +131,12 @@ export interface Photo {
total_ratings?: number; total_ratings?: number;
comment_count?: number; comment_count?: number;
like_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; favorite_count?: number;
} }