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:
@@ -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;
|
||||
@@ -358,6 +360,29 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
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
|
||||
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,
|
||||
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' } : {})
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } : {})
|
||||
|
||||
+8
-28
@@ -39,9 +39,9 @@
|
||||
<meta name="twitter:description" content="${BRAND_DESCRIPTION}" />
|
||||
|
||||
<!-- Pre-React theme bootstrap (#358).
|
||||
The browser may paint the very first frame before our inline
|
||||
<script> below runs, so we set OS-preference defaults via CSS
|
||||
here in <head> — that gets applied before any paint. The
|
||||
The browser may paint the very first frame before the bootstrap
|
||||
<script> below fetches and runs, so we set OS-preference defaults
|
||||
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
|
||||
available. Without this CSS, the very first frame on first-
|
||||
visit dark-OS devices flashed white briefly (see Rekoo-PS's
|
||||
@@ -56,31 +56,11 @@
|
||||
html { transition: background-color 200ms ease; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
/*
|
||||
* Pre-React theme bootstrap (#358).
|
||||
*
|
||||
* The CSS @media block above handles the OS-preference default
|
||||
* 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>
|
||||
<!-- Pre-React theme bootstrap (#358). External rather than inline so a
|
||||
strict CSP without 'unsafe-inline' / hash / nonce — like the one
|
||||
Caddy puts in front of demo.picpeak.app — doesn't block it (#564).
|
||||
No defer/async: must run before <body> paints. -->
|
||||
<script src="/bootstrap.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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_<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 on;
|
||||
gzip_vary on;
|
||||
|
||||
Vendored
+32
@@ -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 */ }
|
||||
})();
|
||||
@@ -48,13 +48,27 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ 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 <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 = () => (
|
||||
<div className="flex items-center gap-2">
|
||||
{!logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && (
|
||||
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain" />
|
||||
// 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).
|
||||
<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') && (
|
||||
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
|
||||
{showText && (
|
||||
<span className={`${wordmarkVisibilityClass} text-xl sm:text-2xl truncate`} style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -69,6 +69,14 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||
// Seed from server is_liked on first non-empty payload (#590 follow-up).
|
||||
// Mount-only so refetches don't clobber in-session optimistic toggles.
|
||||
const likedSeededRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (likedSeededRef.current || photos.length === 0) return;
|
||||
setLikedIds(new Set(photos.filter(p => p.is_liked).map(p => p.id)));
|
||||
likedSeededRef.current = true;
|
||||
}, [photos]);
|
||||
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
||||
|
||||
return (
|
||||
@@ -158,7 +166,13 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedIds(prev => new Set(prev).add(currentPhoto.id));
|
||||
// Toggle — server /feedback like is a toggle (#590).
|
||||
setLikedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(currentPhoto.id)) next.delete(currentPhoto.id);
|
||||
else next.add(currentPhoto.id);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
|
||||
feedback_type: 'like',
|
||||
@@ -171,7 +185,13 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
setShowIdentityModal(true);
|
||||
return;
|
||||
}
|
||||
setLikedIds(prev => new Set(prev).add(currentPhoto.id));
|
||||
// Toggle — server /feedback like is a toggle (#590).
|
||||
setLikedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(currentPhoto.id)) next.delete(currentPhoto.id);
|
||||
else next.add(currentPhoto.id);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
|
||||
feedback_type: 'like',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { MasonryPhotoAlbum } from 'react-photo-album';
|
||||
import 'react-photo-album/masonry.css';
|
||||
import Lightbox from 'yet-another-react-lightbox';
|
||||
@@ -199,6 +199,14 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
const [lightboxIndex, setLightboxIndex] = useState(-1);
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
|
||||
// Seed from server is_liked on first non-empty payload (#590 follow-up).
|
||||
// Mount-only so refetches don't clobber in-session optimistic toggles.
|
||||
const likedSeededRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (likedSeededRef.current || photos.length === 0) return;
|
||||
setLikedPhotoIds(new Set(photos.filter(p => p.is_liked).map(p => p.id)));
|
||||
likedSeededRef.current = true;
|
||||
}, [photos]);
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
@@ -260,9 +268,11 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Toggle — server /feedback like is a toggle (#590).
|
||||
setLikedPhotoIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(photo.id);
|
||||
if (next.has(photo.id)) next.delete(photo.id);
|
||||
else next.add(photo.id);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
@@ -282,10 +292,11 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
// Optimistic update — toggle, not add (#590).
|
||||
setLikedPhotoIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(photo.id);
|
||||
if (next.has(photo.id)) next.delete(photo.id);
|
||||
else next.add(photo.id);
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -306,9 +317,14 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
setShowIdentityModal(false);
|
||||
|
||||
if (pendingLikePhotoId) {
|
||||
// Toggle — server /feedback like is a toggle (#590). The identity
|
||||
// modal only fires the first time per session, so the user is
|
||||
// intentionally liking a not-yet-liked photo here, but keep the
|
||||
// setter shape consistent with the other paths.
|
||||
setLikedPhotoIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(pendingLikePhotoId);
|
||||
if (next.has(pendingLikePhotoId)) next.delete(pendingLikePhotoId);
|
||||
else next.add(pendingLikePhotoId);
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -510,7 +526,10 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
}}
|
||||
isSelected={selectedPhotos.has(originalPhoto.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isLiked={likedPhotoIds.has(originalPhoto.id) || (originalPhoto.like_count ?? 0) > 0}
|
||||
// #590 follow-up: drop the `|| like_count > 0` fallback,
|
||||
// which treated "anyone liked this" as "I liked it". The
|
||||
// per-viewer is_liked seed above is the correct source.
|
||||
isLiked={likedPhotoIds.has(originalPhoto.id)}
|
||||
slug={slug}
|
||||
allowDownloads={allowDownloads}
|
||||
protectionLevel={protectionLevel}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Search, Heart, Menu, LogOut } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -87,15 +87,16 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Initialize favorites from photo like_counts
|
||||
// Seed favorites from per-viewer is_liked on first non-empty payload
|
||||
// (#590 follow-up). The previous code seeded from like_count > 0 which
|
||||
// marked every photo with ANY likes as "favorited" for the current
|
||||
// viewer — wrong. Also gated by a mount-only ref so refetches don't
|
||||
// clobber the user's in-session toggles.
|
||||
const favoritesSeededRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const initialFavorites = new Set<number>();
|
||||
photos.forEach(photo => {
|
||||
if ((photo.like_count ?? 0) > 0) {
|
||||
initialFavorites.add(photo.id);
|
||||
}
|
||||
});
|
||||
setFavorites(initialFavorites);
|
||||
if (favoritesSeededRef.current || photos.length === 0) return;
|
||||
setFavorites(new Set(photos.filter(p => p.is_liked).map(p => p.id)));
|
||||
favoritesSeededRef.current = true;
|
||||
}, [photos]);
|
||||
|
||||
// Get hero photo
|
||||
@@ -138,27 +139,23 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
|
||||
const handleToggleFavorite = useCallback(async (photoId: number) => {
|
||||
const newFavorites = new Set(favorites);
|
||||
const isCurrentlyFavorite = newFavorites.has(photoId);
|
||||
|
||||
if (isCurrentlyFavorite) {
|
||||
newFavorites.delete(photoId);
|
||||
} else {
|
||||
newFavorites.add(photoId);
|
||||
}
|
||||
if (newFavorites.has(photoId)) newFavorites.delete(photoId);
|
||||
else newFavorites.add(photoId);
|
||||
setFavorites(newFavorites);
|
||||
|
||||
// Only submit like if adding favorite
|
||||
if (!isCurrentlyFavorite) {
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug, String(photoId), {
|
||||
feedback_type: 'like',
|
||||
guest_name: savedIdentity?.name,
|
||||
guest_email: savedIdentity?.email,
|
||||
});
|
||||
onFeedbackChange?.();
|
||||
} catch (err) {
|
||||
console.warn('Like submit failed', err);
|
||||
}
|
||||
// The server /feedback like endpoint is a toggle (#590) — fire on
|
||||
// every click, not only when adding. The previous code skipped the
|
||||
// submit on unlike, so the UI removed the heart but the server
|
||||
// still had the like row.
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug, String(photoId), {
|
||||
feedback_type: 'like',
|
||||
guest_name: savedIdentity?.name,
|
||||
guest_email: savedIdentity?.email,
|
||||
});
|
||||
onFeedbackChange?.();
|
||||
} catch (err) {
|
||||
console.warn('Like submit failed', err);
|
||||
}
|
||||
}, [favorites, slug, savedIdentity, onFeedbackChange]);
|
||||
|
||||
|
||||
@@ -403,6 +403,14 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
|
||||
const [likedPhotoIds, setLikedPhotoIds] = React.useState<Set<number>>(new Set());
|
||||
// Seed from server is_liked on first non-empty payload (#590 follow-up).
|
||||
// Mount-only so refetches don't clobber in-session optimistic toggles.
|
||||
const likedSeededRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (likedSeededRef.current || photos.length === 0) return;
|
||||
setLikedPhotoIds(new Set(photos.filter(p => p.is_liked).map(p => p.id)));
|
||||
likedSeededRef.current = true;
|
||||
}, [photos]);
|
||||
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
|
||||
|
||||
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
|
||||
@@ -443,9 +451,12 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
onFeedbackChange={onFeedbackChange}
|
||||
liked={likedPhotoIds.has(photo.id)}
|
||||
onLikeSuccess={() => {
|
||||
// Toggle, not add — like endpoint toggles server-side,
|
||||
// so the optimistic UI has to follow suit on click 2 (#590).
|
||||
setLikedPhotoIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(photo.id);
|
||||
if (next.has(photo.id)) next.delete(photo.id);
|
||||
else next.add(photo.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
@@ -482,11 +493,12 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
guest_name: name,
|
||||
guest_email: email,
|
||||
});
|
||||
// Immediately reflect like UI
|
||||
// Immediately reflect like UI — toggle for consistency (#590).
|
||||
if (pendingAction.type === 'like') {
|
||||
setLikedPhotoIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(pendingAction.photoId);
|
||||
if (next.has(pendingAction.photoId)) next.delete(pendingAction.photoId);
|
||||
else next.add(pendingAction.photoId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -543,6 +543,14 @@ export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
|
||||
null
|
||||
);
|
||||
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
|
||||
// Seed from server is_liked on first non-empty payload (#590 follow-up).
|
||||
// Mount-only so refetches don't clobber in-session optimistic toggles.
|
||||
const likedSeededRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (likedSeededRef.current || photos.length === 0) return;
|
||||
setLikedPhotoIds(new Set(photos.filter(p => p.is_liked).map(p => p.id)));
|
||||
likedSeededRef.current = true;
|
||||
}, [photos]);
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
|
||||
// Track container width with ResizeObserver
|
||||
@@ -763,9 +771,12 @@ export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
|
||||
onFeedbackChange={onFeedbackChange}
|
||||
liked={likedPhotoIds.has(photo.id)}
|
||||
onLikeSuccess={() => {
|
||||
// Toggle, not add — like endpoint toggles server-side,
|
||||
// so the optimistic UI has to follow suit on click 2 (#590).
|
||||
setLikedPhotoIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(photo.id);
|
||||
if (next.has(photo.id)) next.delete(photo.id);
|
||||
else next.add(photo.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
@@ -788,10 +799,12 @@ export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
|
||||
guest_name: name,
|
||||
guest_email: email,
|
||||
});
|
||||
// Toggle for consistency (#590).
|
||||
if (pendingAction.type === 'like') {
|
||||
setLikedPhotoIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(pendingAction.photoId);
|
||||
if (next.has(pendingAction.photoId)) next.delete(pendingAction.photoId);
|
||||
else next.add(pendingAction.photoId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -281,6 +281,15 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
// Optimistic "I liked this" state — lifted here so it survives re-renders
|
||||
// of individual MasonryPhoto components during layout reflow/resize.
|
||||
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
|
||||
// Seed from server is_liked on first non-empty photos payload (#590
|
||||
// follow-up). Mount-only: subsequent refetches don't clobber in-session
|
||||
// optimistic toggles, only the first arrival of photos initializes.
|
||||
const likedSeededRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (likedSeededRef.current || photos.length === 0) return;
|
||||
setLikedPhotoIds(new Set(photos.filter(p => p.is_liked).map(p => p.id)));
|
||||
likedSeededRef.current = true;
|
||||
}, [photos]);
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const gutter = gallerySettings.masonryGutter || 16;
|
||||
const mode = gallerySettings.masonryMode || 'columns';
|
||||
@@ -832,9 +841,13 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
columnWidth={columnWidth}
|
||||
liked={likedPhotoIds.has(photo.id)}
|
||||
onLikeSuccess={() => {
|
||||
// Toggle, not add — the /feedback like endpoint toggles
|
||||
// server-side, so click 2 on a liked photo unlikes it;
|
||||
// the optimistic UI must follow suit (#590).
|
||||
setLikedPhotoIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(photo.id);
|
||||
if (next.has(photo.id)) next.delete(photo.id);
|
||||
else next.add(photo.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -55,7 +55,9 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
|
||||
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [likedLocal, setLikedLocal] = React.useState(false);
|
||||
// Seed from server is_liked (#590 follow-up). useState's initializer
|
||||
// fires once on mount, so subsequent prop updates don't reseed.
|
||||
const [likedLocal, setLikedLocal] = React.useState(photo.is_liked ?? false);
|
||||
const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment);
|
||||
|
||||
// Calculate aspect ratio from photo dimensions (fallback to 1 if unknown)
|
||||
@@ -116,7 +118,8 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedLocal(true);
|
||||
// Toggle — server /feedback like is a toggle (#590).
|
||||
setLikedLocal(prev => !prev);
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
@@ -129,7 +132,8 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
setShowIdentityModal(true);
|
||||
return;
|
||||
}
|
||||
setLikedLocal(true);
|
||||
// Toggle — server /feedback like is a toggle (#590).
|
||||
setLikedLocal(prev => !prev);
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Download, Maximize2, Check, Calendar, Heart, MessageSquare } from 'lucide-react';
|
||||
import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
@@ -24,6 +24,14 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||
// Seed from server is_liked on first non-empty payload (#590 follow-up).
|
||||
// Mount-only so refetches don't clobber in-session optimistic toggles.
|
||||
const likedSeededRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (likedSeededRef.current || photos.length === 0) return;
|
||||
setLikedIds(new Set(photos.filter(p => p.is_liked).map(p => p.id)));
|
||||
likedSeededRef.current = true;
|
||||
}, [photos]);
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
@@ -155,7 +163,13 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedIds(prev => new Set(prev).add(photo.id));
|
||||
// Toggle — server /feedback like is a toggle (#590).
|
||||
setLikedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(photo.id)) next.delete(photo.id);
|
||||
else next.add(photo.id);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
@@ -168,7 +182,13 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
setShowIdentityModal(true);
|
||||
return;
|
||||
}
|
||||
setLikedIds(prev => new Set(prev).add(photo.id));
|
||||
// Toggle — server /feedback like is a toggle (#590).
|
||||
setLikedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(photo.id)) next.delete(photo.id);
|
||||
else next.add(photo.id);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user