feat(gallery): colour labels for client proofing, and one global default per feedback type (#1044) (#1137)

Colour labels for client proofing, plus the photographer's own stars and colours in the admin grid.

- Guest colour labels alongside likes/reactions, opt-in per event (defaults off so live galleries do not change mid-proofing), with 'colors' and 'lightroom' keybind schemes.
- One global default per feedback type, replacing the per-type scatter.
- Admin marks live in their own table (photo_admin_marks) so they can never reach a guest-facing surface.
- XMP export prefers a real label, keeping the rating-derived mapping as a fallback.

Review: concurrent-write loss on the mark update path, migration index idempotency and error classification all fixed in 7139bcae; migrations renumbered to 182/183 in 8fecdfae after 180/181 were taken on main.

Merged with admin privileges: bypass-size-gate is a required check that fails on size alone for review-bypass authors and never re-evaluates on review, which is its designed behaviour once a maintainer has approved.
This commit is contained in:
Luca
2026-08-23 11:15:01 +02:00
committed by GitHub
parent 7b77bbf243
commit e2844d1909
60 changed files with 3912 additions and 182 deletions
@@ -0,0 +1,176 @@
/**
* Global guest-feedback defaults (#1044).
*
* Before this module the creation defaults lived in four places that had
* already drifted: the admin create route's destructuring defaults, the v1
* create route's hard-coded insert (which omitted `allow_reactions`
* entirely), getEventFeedbackSettings()'s no-row fallback, and the migration
* column defaults. Pinned here:
*
* - with no app_settings rows, the built-in fallbacks apply (so installs
* that never open Settings > Events keep behaving exactly as before)
* - a stored global overrides its fallback, whether it was written as a JSON
* boolean, a bare "true"/"false" string, or SQLite's 0/1
* - garbage in a settings row falls back rather than writing null into a
* boolean column
* - an explicitly-sent body value always beats the global
* - keybind_mode only accepts the known schemes
* - getEventFeedbackSettings()'s no-row fallback reads the same globals
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-feedback-defaults-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-defaults-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const {
FEEDBACK_TOGGLES,
resolveEventFeedbackDefaults,
applyFeedbackDefaults,
} = require('../../src/services/feedbackDefaults');
const feedbackService = require('../../src/services/feedbackService');
let db;
let cleanup;
async function setGlobal(key, value) {
await db('app_settings').where('setting_key', key).delete();
await db('app_settings').insert({
setting_key: key,
setting_value: value,
setting_type: 'general',
updated_at: new Date().toISOString(),
});
}
async function clearGlobals() {
await db('app_settings').where('setting_key', 'like', 'event_default_%').delete();
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await clearGlobals();
});
describe('resolveEventFeedbackDefaults (#1044)', () => {
it('falls back to the built-ins when nothing is configured', async () => {
const defaults = await resolveEventFeedbackDefaults();
expect(defaults).toEqual({
allow_ratings: true,
allow_likes: true,
allow_favorites: true,
allow_comments: true,
allow_reactions: true,
// Colour labels are opt-in so no existing gallery grows a colour bar
// on upgrade.
allow_color_labels: false,
keybind_mode: 'colors',
});
});
it('covers every toggle the UI shows — no type can be silently missing', async () => {
const defaults = await resolveEventFeedbackDefaults();
for (const toggle of FEEDBACK_TOGGLES) {
expect(defaults).toHaveProperty(toggle.column);
}
});
it('lets a stored global override its fallback', async () => {
await setGlobal('event_default_allow_comments', JSON.stringify(false));
await setGlobal('event_default_allow_color_labels', JSON.stringify(true));
const defaults = await resolveEventFeedbackDefaults();
expect(defaults.allow_comments).toBe(false);
expect(defaults.allow_color_labels).toBe(true);
expect(defaults.allow_likes).toBe(true); // untouched
});
it('accepts the value shapes older writers left behind', async () => {
await setGlobal('event_default_allow_likes', 'false'); // bare string, not JSON-quoted
await setGlobal('event_default_allow_ratings', '0'); // SQLite boolean
const defaults = await resolveEventFeedbackDefaults();
expect(defaults.allow_likes).toBe(false);
expect(defaults.allow_ratings).toBe(false);
});
it('falls back rather than writing garbage into a boolean column', async () => {
await setGlobal('event_default_allow_favorites', 'not-a-boolean');
const defaults = await resolveEventFeedbackDefaults();
expect(defaults.allow_favorites).toBe(true);
});
it('only accepts known keybind schemes', async () => {
await setGlobal('event_default_keybind_mode', JSON.stringify('lightroom'));
expect((await resolveEventFeedbackDefaults()).keybind_mode).toBe('lightroom');
await setGlobal('event_default_keybind_mode', JSON.stringify('vim'));
expect((await resolveEventFeedbackDefaults()).keybind_mode).toBe('colors');
});
});
describe('applyFeedbackDefaults (#1044)', () => {
const globals = {
allow_ratings: true,
allow_likes: true,
allow_favorites: true,
allow_comments: true,
allow_reactions: true,
allow_color_labels: false,
keybind_mode: 'colors',
};
it('inherits every value the caller omitted', () => {
expect(applyFeedbackDefaults({}, globals)).toEqual(globals);
expect(applyFeedbackDefaults(undefined, globals)).toEqual(globals);
});
it('lets an explicit body value win — including an explicit false', () => {
const resolved = applyFeedbackDefaults(
{ allow_likes: false, allow_color_labels: true, keybind_mode: 'lightroom' },
globals,
);
expect(resolved.allow_likes).toBe(false);
expect(resolved.allow_color_labels).toBe(true);
expect(resolved.keybind_mode).toBe('lightroom');
expect(resolved.allow_ratings).toBe(true); // still inherited
});
it('ignores an unknown keybind mode from the body', () => {
expect(applyFeedbackDefaults({ keybind_mode: 'emacs' }, globals).keybind_mode).toBe('colors');
});
it('does not mutate the globals object it was handed', () => {
const snapshot = { ...globals };
applyFeedbackDefaults({ allow_likes: false }, globals);
expect(globals).toEqual(snapshot);
});
});
describe('getEventFeedbackSettings no-row fallback (#1044)', () => {
it('reads the same globals instead of a fourth hard-coded copy', async () => {
await setGlobal('event_default_allow_comments', JSON.stringify(false));
await setGlobal('event_default_allow_color_labels', JSON.stringify(true));
// An event id with no event_feedback_settings row.
const settings = await feedbackService.getEventFeedbackSettings(999999);
expect(settings.feedback_enabled).toBe(false); // no row still means feedback off
expect(settings.allow_comments).toBe(false);
expect(settings.allow_color_labels).toBe(true);
expect(settings.keybind_mode).toBe('colors');
});
});
@@ -0,0 +1,241 @@
/**
* The photographer's own stars and colour labels (#1044 follow-up).
*
* The property that matters most here is isolation: admin marks live in their
* own table precisely so they can never reach a guest-facing surface. Pinned:
*
* - rating and colour are independent halves of one mark — writing one must
* not wipe the other
* - a mark with neither half left is deleted, not kept as an empty row
* - two admins on the same event keep separate marks
* - marks never touch photo_feedback or the denormalized photos.* counters
* the gallery reads
* - invalid values are rejected the same way guest colour labels are
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admin-marks-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-marks-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const marks = require('../../src/services/photoAdminMarksService');
const feedbackService = require('../../src/services/feedbackService');
const ADMIN_A = 11;
const ADMIN_B = 22;
let db;
let cleanup;
let eventId;
let photoIds;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: 'admin-marks-test-event',
event_type: 'wedding',
event_name: 'Admin Marks Test',
event_date: '2026-07-20',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: 'x',
share_link: '/gallery/admin-marks-test-event/share',
share_token: 'admin-marks-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
photoIds = [];
for (let i = 0; i < 3; i++) {
const photo = await db('photos').insert({
event_id: eventId,
filename: `photo-${i}.jpg`,
path: `events/admin-marks/${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(photo[0]?.id ?? photo[0]);
}
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('photoAdminMarksService (#1044 follow-up)', () => {
it('sets a colour without touching the rating, and vice versa', async () => {
expect(await marks.setMark(eventId, photoIds[0], ADMIN_A, { colorLabel: 'green' }))
.toEqual({ rating: null, color_label: 'green' });
// Writing the rating must leave the colour alone — the two lightbox key
// groups write independently.
expect(await marks.setMark(eventId, photoIds[0], ADMIN_A, { rating: 4 }))
.toEqual({ rating: 4, color_label: 'green' });
expect(await marks.setMark(eventId, photoIds[0], ADMIN_A, { colorLabel: 'red' }))
.toEqual({ rating: 4, color_label: 'red' });
});
it('keeps exactly one row per photo per admin', async () => {
const rows = await db('photo_admin_marks')
.where({ photo_id: photoIds[0], admin_id: ADMIN_A });
expect(rows).toHaveLength(1);
});
it('clears one half with null and leaves the other', async () => {
expect(await marks.setMark(eventId, photoIds[0], ADMIN_A, { colorLabel: null }))
.toEqual({ rating: 4, color_label: null });
});
it('deletes the row once neither half is left', async () => {
expect(await marks.setMark(eventId, photoIds[0], ADMIN_A, { rating: null })).toBeNull();
const rows = await db('photo_admin_marks')
.where({ photo_id: photoIds[0], admin_id: ADMIN_A });
expect(rows).toHaveLength(0);
});
it('keeps two admins on the same photo independent', async () => {
await marks.setMark(eventId, photoIds[1], ADMIN_A, { colorLabel: 'green' });
await marks.setMark(eventId, photoIds[1], ADMIN_B, { colorLabel: 'red', rating: 2 });
expect((await marks.getEventMarks(eventId, ADMIN_A))[photoIds[1]])
.toEqual({ rating: null, color_label: 'green' });
expect((await marks.getEventMarks(eventId, ADMIN_B))[photoIds[1]])
.toEqual({ rating: 2, color_label: 'red' });
});
it('rejects colours outside the set and ratings outside 1-5', async () => {
await expect(marks.setMark(eventId, photoIds[2], ADMIN_A, { colorLabel: 'chartreuse' }))
.rejects.toThrow('Invalid color label');
await expect(marks.setMark(eventId, photoIds[2], ADMIN_A, { rating: 6 }))
.rejects.toThrow('Rating must be between 1 and 5');
await expect(marks.setMark(eventId, photoIds[2], ADMIN_A, { rating: 0 }))
.rejects.toThrow('Rating must be between 1 and 5');
// Nothing was written by any of the rejected calls.
expect(await marks.getEventMarks(eventId, ADMIN_A, [photoIds[2]])).toEqual({});
});
it('tags caller mistakes with a code, not just a message', async () => {
// The route maps this to a 400. Keyed on the code so rewording the
// message can't silently turn a bad request into a 500.
await expect(marks.setMark(eventId, photoIds[2], ADMIN_A, { rating: 9 }))
.rejects.toMatchObject({ code: marks.INVALID_MARK });
await expect(marks.setMark(eventId, photoIds[2], ADMIN_A, { colorLabel: 'beige' }))
.rejects.toMatchObject({ code: marks.INVALID_MARK });
});
it('narrows to a page of photo ids', async () => {
expect(Object.keys(await marks.getEventMarks(eventId, ADMIN_A, [photoIds[1]])))
.toEqual([String(photoIds[1])]);
expect(await marks.getEventMarks(eventId, ADMIN_A, [])).toEqual({});
});
it('converges instead of 500ing when two marks race into the same row', async () => {
// Simulate the check-then-insert race a keyboard triage pass produces:
// both calls read "no row", one inserts, the other hits the unique index.
// The loser must land on the winner's row, not throw — and the half it
// did not address must keep the winner's value, not the stale pre-read.
const photoId = photoIds[2];
await db('photo_admin_marks').where({ photo_id: photoId }).delete();
const [first, second] = await Promise.all([
marks.setMark(eventId, photoId, ADMIN_A, { rating: 4 }),
marks.setMark(eventId, photoId, ADMIN_A, { colorLabel: 'green' }),
]);
expect(first).not.toBeNull();
expect(second).not.toBeNull();
const rows = await db('photo_admin_marks').where({ photo_id: photoId, admin_id: ADMIN_A });
expect(rows).toHaveLength(1);
// Whichever order they landed in, neither half may be lost.
expect(rows[0].rating).toBe(4);
expect(rows[0].color_label).toBe('green');
await db('photo_admin_marks').where({ photo_id: photoId }).delete();
});
it('does not lose a concurrent half when the row already exists', async () => {
// luap's finding on #1137: the insert race was handled, but two keystrokes
// on a photo that ALREADY has a mark took a plain read-modify-write —
// both calls read the old row, both wrote the full pair, and the second
// clobbered the first with its stale value. That is the COMMON case in a
// triage pass, since by the second keystroke there usually is a row.
const photoId = photoIds[2];
await db('photo_admin_marks').where({ photo_id: photoId }).delete();
await marks.setMark(eventId, photoId, ADMIN_A, { rating: 3 });
await Promise.all([
marks.setMark(eventId, photoId, ADMIN_A, { rating: 4 }),
marks.setMark(eventId, photoId, ADMIN_A, { colorLabel: 'blue' }),
]);
const row = await db('photo_admin_marks')
.where({ photo_id: photoId, admin_id: ADMIN_A }).first();
expect(row.rating).toBe(4); // was 3 before the fix — the colour
expect(row.color_label).toBe('blue'); // write reverted it
await db('photo_admin_marks').where({ photo_id: photoId }).delete();
});
it('leaves the untouched half alone even when the caller sends only one', async () => {
// The mechanism behind the fix: an unaddressed column is never written,
// so it cannot be written with a stale value.
const photoId = photoIds[2];
await db('photo_admin_marks').where({ photo_id: photoId }).delete();
await marks.setMark(eventId, photoId, ADMIN_A, { rating: 2, colorLabel: 'purple' });
const before = await db('photo_admin_marks')
.where({ photo_id: photoId, admin_id: ADMIN_A }).first();
await marks.setMark(eventId, photoId, ADMIN_A, { colorLabel: 'green' });
const after = await db('photo_admin_marks')
.where({ photo_id: photoId, admin_id: ADMIN_A }).first();
expect(after.rating).toBe(before.rating);
expect(after.color_label).toBe('green');
await db('photo_admin_marks').where({ photo_id: photoId }).delete();
});
it('counts colours per admin for the filter chips', async () => {
await marks.setMark(eventId, photoIds[2], ADMIN_A, { colorLabel: 'green' });
expect(await marks.getEventMarkColorCounts(eventId, ADMIN_A)).toEqual({ green: 2 });
expect(await marks.getEventMarkColorCounts(eventId, ADMIN_B)).toEqual({ red: 1 });
// A rating-only mark contributes no colour count.
await marks.setMark(eventId, photoIds[0], ADMIN_A, { rating: 5 });
expect(await marks.getEventMarkColorCounts(eventId, ADMIN_A)).toEqual({ green: 2 });
});
it('never leaks into guest feedback or the denormalized gallery counters', async () => {
// Everything above wrote marks on all three photos.
expect(await db('photo_feedback').count('* as count').first())
.toEqual(expect.objectContaining({ count: 0 }));
for (const photoId of photoIds) {
// updatePhotoFeedbackStats is what the gallery payload reads; it must
// still see an untouched photo.
await feedbackService.updatePhotoFeedbackStats(photoId);
const photo = await db('photos').where('id', photoId).first();
expect(Number(photo.color_label_count) || 0).toBe(0);
expect(Number(photo.average_rating) || 0).toBe(0);
expect(Number(photo.feedback_count) || 0).toBe(0);
}
// And the guest-facing tallies stay empty.
expect(await feedbackService.getPhotoColorLabelCounts(photoIds[1])).toEqual({});
expect(await feedbackService.getEventColorLabelCounts(eventId)).toEqual({});
});
});
@@ -0,0 +1,268 @@
/**
* Colour labels (#1044) — pins the contract of the `color_label` feedback
* type, which shares its single-value machinery with emoji reactions (#839):
* - only the five Lightroom colours are accepted
* - one label per guest per photo: the same colour again toggles OFF,
* a different colour SWITCHES the existing row (never a second row)
* - per-guest scoping mirrors reactions: guest_id when present, else the
* device-hash guest_identifier
* - a check-then-insert race that produced duplicate rows collapses on the
* next interaction instead of leaving a phantom count
* - denormalized photos.color_label_count and the per-colour tallies follow
* visibility: hidden-by-moderator labels disappear from both
* - the exports carry the colour
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-feedback-color-labels-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-color-labels-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const feedbackService = require('../../src/services/feedbackService');
const { COLOR_LABELS, dominantColorLabel } = require('../../src/constants/colorLabels');
const { XmpGenerator } = require('../../src/services/xmpGenerator');
const EVENT_SLUG = 'color-labels-test-event';
const GUEST_A = 'guest-a-identifier';
const GUEST_B = 'guest-b-identifier';
let db;
let cleanup;
let eventId;
let photoIds;
async function label(photoId, color, { guestIdentifier = GUEST_A, guestId = null } = {}) {
return feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'color_label',
color_label: color,
guest_id: guestId,
ip_address: '127.0.0.1',
user_agent: 'jest',
}, guestIdentifier);
}
async function colorLabelCountOf(photoId) {
const row = await db('photos').where('id', photoId).first();
return Number(row.color_label_count) || 0;
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: EVENT_SLUG,
event_type: 'wedding',
event_name: 'Colour Labels Test',
event_date: '2026-07-20',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: 'x',
share_link: `/gallery/${EVENT_SLUG}/share`,
share_token: 'color-labels-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
photoIds = [];
for (let i = 0; i < 3; i++) {
const photo = await db('photos').insert({
event_id: eventId,
filename: `photo-${i}.jpg`,
path: `events/color-labels/${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(photo[0]?.id ?? photo[0]);
}
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('colour label submission (#1044)', () => {
it('rejects colours outside the Lightroom set', async () => {
await expect(label(photoIds[0], 'chartreuse')).rejects.toThrow('Invalid color label');
await expect(label(photoIds[0], 'Green')).rejects.toThrow('Invalid color label'); // case matters
await expect(label(photoIds[0], undefined)).rejects.toThrow('Invalid color label');
expect(await colorLabelCountOf(photoIds[0])).toBe(0);
});
it('creates a label row and maintains the denormalized count', async () => {
const result = await label(photoIds[0], 'green');
expect(result.created).toBe(true);
const row = await db('photo_feedback')
.where({ photo_id: photoIds[0], feedback_type: 'color_label' })
.first();
expect(row.color_label).toBe('green');
expect(await colorLabelCountOf(photoIds[0])).toBe(1);
expect(await feedbackService.getPhotoColorLabelCounts(photoIds[0])).toEqual({ green: 1 });
});
it('switches to another colour in place — never a second row per guest', async () => {
const result = await label(photoIds[0], 'red');
expect(result.updated).toBe(true);
const rows = await db('photo_feedback')
.where({ photo_id: photoIds[0], feedback_type: 'color_label' });
expect(rows).toHaveLength(1);
expect(rows[0].color_label).toBe('red');
expect(await feedbackService.getPhotoColorLabelCounts(photoIds[0])).toEqual({ red: 1 });
});
it('tallies different guests per colour', async () => {
await label(photoIds[0], 'red', { guestIdentifier: GUEST_B });
expect(await feedbackService.getPhotoColorLabelCounts(photoIds[0])).toEqual({ red: 2 });
expect(await colorLabelCountOf(photoIds[0])).toBe(2);
});
it('toggles off with the same colour', async () => {
const result = await label(photoIds[0], 'red');
expect(result.removed).toBe(true);
expect(await feedbackService.getPhotoColorLabelCounts(photoIds[0])).toEqual({ red: 1 }); // GUEST_B remains
expect(await colorLabelCountOf(photoIds[0])).toBe(1);
});
it('scopes per guest_id when present — two token-guests on one device stay independent', async () => {
const first = await label(photoIds[1], 'green', { guestIdentifier: GUEST_A, guestId: 101 });
const second = await label(photoIds[1], 'blue', { guestIdentifier: GUEST_A, guestId: 102 });
expect(first.created).toBe(true);
expect(second.created).toBe(true); // NOT treated as guest 101's switch
expect(await feedbackService.getPhotoColorLabelCounts(photoIds[1]))
.toEqual({ green: 1, blue: 1 });
});
it('accepts every colour of the set', async () => {
for (const color of COLOR_LABELS) {
const res = await label(photoIds[2], color, { guestIdentifier: `guest-${color}` });
expect(res.created).toBe(true);
}
const counts = await feedbackService.getPhotoColorLabelCounts(photoIds[2]);
expect(Object.keys(counts).sort()).toEqual([...COLOR_LABELS].sort());
});
it('hidden labels leave both the per-colour tallies and color_label_count', async () => {
const row = await db('photo_feedback')
.where({ photo_id: photoIds[0], feedback_type: 'color_label' })
.first();
await feedbackService.moderateFeedback(row.id, 'hide', 1);
expect(await feedbackService.getPhotoColorLabelCounts(photoIds[0])).toEqual({});
expect(await colorLabelCountOf(photoIds[0])).toBe(0);
await feedbackService.moderateFeedback(row.id, 'approve', 1);
expect(await colorLabelCountOf(photoIds[0])).toBe(1);
});
it('toggle and switch collapse racy duplicate rows for the same guest', async () => {
// Simulate the check-then-insert race: two rows for one guest+photo.
const mk = (color) => ({
photo_id: photoIds[1], event_id: eventId, feedback_type: 'color_label',
color_label: color, guest_identifier: 'dup-guest', is_approved: true, is_hidden: false,
created_at: new Date(), updated_at: new Date(),
});
await db('photo_feedback').insert([mk('yellow'), mk('yellow')]);
// Switching converges to exactly ONE row with the new colour…
const switched = await label(photoIds[1], 'purple', { guestIdentifier: 'dup-guest' });
expect(switched.updated).toBe(true);
let rows = await db('photo_feedback')
.where({ photo_id: photoIds[1], feedback_type: 'color_label', guest_identifier: 'dup-guest' });
expect(rows).toHaveLength(1);
expect(rows[0].color_label).toBe('purple');
// …and toggle-off removes the full guest-scoped set.
await db('photo_feedback').insert(mk('purple'));
const removed = await label(photoIds[1], 'purple', { guestIdentifier: 'dup-guest' });
expect(removed.removed).toBe(true);
rows = await db('photo_feedback')
.where({ photo_id: photoIds[1], feedback_type: 'color_label', guest_identifier: 'dup-guest' });
expect(rows).toHaveLength(0);
});
it('reactions and colour labels coexist on the same photo and guest', async () => {
await feedbackService.submitFeedback(photoIds[0], eventId, {
feedback_type: 'reaction',
reaction: '❤️',
ip_address: '127.0.0.1',
user_agent: 'jest',
}, GUEST_B);
await label(photoIds[0], 'blue', { guestIdentifier: GUEST_B });
// Switching the colour must not disturb the reaction row, and vice versa.
expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({ '❤️': 1 });
expect(await feedbackService.getPhotoColorLabelCounts(photoIds[0])).toEqual({ blue: 1 });
});
it('event-wide colour counts group per photo', async () => {
const byPhoto = await feedbackService.getEventColorLabelCounts(eventId);
expect(byPhoto[photoIds[2]]).toEqual(
COLOR_LABELS.reduce((acc, c) => ({ ...acc, [c]: 1 }), {}),
);
// Narrowing to a page of ids only returns those.
const narrowed = await feedbackService.getEventColorLabelCounts(eventId, [photoIds[2]]);
expect(Object.keys(narrowed)).toEqual([String(photoIds[2])]);
expect(await feedbackService.getEventColorLabelCounts(eventId, [])).toEqual({});
});
it('summary and exports carry colour labels', async () => {
const summary = await feedbackService.getEventFeedbackSummary(eventId);
expect(Number(summary.stats.total_color_labels)).toBeGreaterThan(0);
const longRows = await feedbackService.exportEventFeedback(eventId);
const longLabel = longRows.find((r) => r.feedback_type === 'color_label');
expect(COLOR_LABELS).toContain(longLabel.color_label);
const pivotRows = await feedbackService.exportEventFeedbackPivoted(eventId);
const pivotWithLabel = pivotRows.find((r) => r.color_label);
expect(COLOR_LABELS).toContain(pivotWithLabel.color_label);
});
});
describe('dominant colour + XMP round-trip (#1044)', () => {
it('picks the most-labelled colour, breaking ties toward the 1st-choice order', () => {
expect(dominantColorLabel({ red: 3, green: 1 })).toBe('red');
// Tie: green is "1st choice" in the proofing workflow, so it survives.
expect(dominantColorLabel({ red: 2, green: 2 })).toBe('green');
expect(dominantColorLabel({})).toBeNull();
expect(dominantColorLabel(null)).toBeNull();
});
it('xmp:Label prefers a real colour label over the rating-derived guess', () => {
const generator = new XmpGenerator();
// A 5-star photo would historically map to 'Red'. An explicit green label wins.
expect(generator.mapLabel({ average_rating: 5, dominant_color_label: 'green' })).toBe('Green');
expect(generator.mapLabel({ average_rating: 5, color_labels: { blue: 1 } })).toBe('Blue');
// No label: unchanged legacy behaviour, so existing exports don't move.
expect(generator.mapLabel({ average_rating: 5 })).toBe('Red');
expect(generator.mapLabel({ average_rating: 0 })).toBeNull();
// Tolerates the pre-#1044 call shape (a bare average rating).
expect(generator.mapLabel(4.6)).toBe('Red');
});
it('writes the label into the sidecar and adds a searchable keyword', () => {
const generator = new XmpGenerator();
const xmp = generator.generateXmp({
filename: 'a.jpg',
average_rating: 0,
like_count: 0,
favorite_count: 0,
dominant_color_label: 'yellow',
color_labels: { yellow: 2 },
});
expect(xmp).toContain('xmp:Label="Yellow"');
expect(xmp).toContain('color-yellow');
});
});
@@ -0,0 +1,94 @@
/**
* Colour labels for client proofing (#1044).
*
* Structurally identical to emoji reactions (migration 164): ONE value per
* guest per photo, changeable, the same value again toggles it off, drawn
* from a fixed curated set (constants/colorLabels.js). No new table.
*
* - event_feedback_settings.allow_color_labels: per-event toggle next to
* allow_reactions. Defaults FALSE, unlike its siblings — a colour bar
* appearing unannounced in every gallery that already has feedback enabled
* would be a visible change to live galleries mid-proofing, so this one is
* opt-in. New events inherit the global `event_default_allow_color_labels`
* via services/feedbackDefaults.js.
* - event_feedback_settings.keybind_mode: which lightbox shortcut scheme the
* gallery uses — 'colors' (1/2/3 = green/yellow/red, simplest for clients)
* or 'lightroom' (1-5 stars, 6-9 colours, identical muscle memory for
* photographers). See constants/colorLabels.js for the maps.
* - photo_feedback.color_label: the colour for feedback_type='color_label'
* rows, validated against COLOR_LABELS.
* - photos.color_label_count: denormalized total, maintained by
* updatePhotoFeedbackStats alongside like_count / reaction_count.
* - photo_feedback_color_label_idx: the admin "show only the greens" filter
* runs as a whereExists over (event_id, feedback_type, color_label).
*/
exports.up = async function (knex) {
const hasAllowColorLabels = await knex.schema.hasColumn('event_feedback_settings', 'allow_color_labels');
if (!hasAllowColorLabels) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.boolean('allow_color_labels').defaultTo(false);
});
}
const hasKeybindMode = await knex.schema.hasColumn('event_feedback_settings', 'keybind_mode');
if (!hasKeybindMode) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.string('keybind_mode', 16).defaultTo('colors');
});
}
const hasColorLabel = await knex.schema.hasColumn('photo_feedback', 'color_label');
if (!hasColorLabel) {
await knex.schema.alterTable('photo_feedback', (table) => {
// Lightroom's colour names, lowercase: red/yellow/green/blue/purple.
table.string('color_label', 16);
});
}
// Outside the column guard on purpose: a run that died between the two
// statements would otherwise leave the column present and the index missing,
// and a re-run would skip both — silently costing the admin colour filter
// the index this migration's header says it relies on. IF NOT EXISTS is
// supported by Postgres and by SQLite (verified idempotent on 3.44), so this
// is safe to re-run in any state.
await knex.raw(
'CREATE INDEX IF NOT EXISTS photo_feedback_color_label_idx '
+ 'ON photo_feedback (event_id, feedback_type, color_label)'
);
const hasColorLabelCount = await knex.schema.hasColumn('photos', 'color_label_count');
if (!hasColorLabelCount) {
await knex.schema.alterTable('photos', (table) => {
table.integer('color_label_count').defaultTo(0);
});
}
};
exports.down = async function (knex) {
if (await knex.schema.hasColumn('photos', 'color_label_count')) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('color_label_count');
});
}
// Drop the index first, and unconditionally: SQLite rebuilds the table on
// dropColumn and a lingering index over the dropped column makes that
// rebuild fail. Unconditional so a down() after a partial up() still clears
// whichever half landed.
await knex.raw('DROP INDEX IF EXISTS photo_feedback_color_label_idx');
if (await knex.schema.hasColumn('photo_feedback', 'color_label')) {
await knex.schema.alterTable('photo_feedback', (table) => {
table.dropColumn('color_label');
});
}
if (await knex.schema.hasColumn('event_feedback_settings', 'keybind_mode')) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.dropColumn('keybind_mode');
});
}
if (await knex.schema.hasColumn('event_feedback_settings', 'allow_color_labels')) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.dropColumn('allow_color_labels');
});
}
};
@@ -0,0 +1,48 @@
/**
* Photographer-side stars and colour labels (#1044 follow-up).
*
* The photographer triages their own shoot with the same 1-5 stars and five
* Lightroom colours the client uses while proofing — but their marks are a
* SEPARATE table rather than more photo_feedback rows, for one reason:
* photo_feedback is read by a long tail of guest-facing queries (the gallery
* payload, the per-photo tallies, the denormalized photos.average_rating /
* *_count columns, the feedback exports, moderation). Adding admin rows there
* would leak the photographer's own opinions into the client's proofing view
* through whichever of those queries someone forgot to filter — and "forgot to
* filter" is exactly the failure this shape makes impossible.
*
* One row per (photo, admin): rating and colour live together because they are
* one person's verdict on one photo, and a row with neither is deleted rather
* than kept as a tombstone.
*/
exports.up = async function (knex) {
const hasTable = await knex.schema.hasTable('photo_admin_marks');
if (!hasTable) {
await knex.schema.createTable('photo_admin_marks', (table) => {
table.increments('id').primary();
table.integer('photo_id').notNullable()
.references('id').inTable('photos').onDelete('CASCADE');
// Denormalized from photos.event_id so the per-event filter and the
// count queries don't have to join photos on every admin grid render.
table.integer('event_id').notNullable();
table.integer('admin_id').notNullable();
table.integer('rating'); // 1-5, NULL = no star rating
table.string('color_label', 16); // NULL = no colour
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// One verdict per photo per admin. Two admins on the same event keep
// their own marks; the same admin marking twice updates in place.
table.unique(['photo_id', 'admin_id'], 'photo_admin_marks_photo_admin_uniq');
table.index(['event_id', 'admin_id'], 'photo_admin_marks_event_admin_idx');
table.index(['event_id', 'color_label'], 'photo_admin_marks_color_idx');
});
}
};
exports.down = async function (knex) {
if (await knex.schema.hasTable('photo_admin_marks')) {
await knex.schema.dropTable('photo_admin_marks');
}
};
+93
View File
@@ -0,0 +1,93 @@
/**
* Colour labels (#1044): the fixed, curated colour set clients mark photos
* with while proofing. Guests pick ONE per photo (changeable), exactly like
* the emoji reactions in constants/reactions.js.
*
* The set is Lightroom's, deliberately: keeping the same five colours means a
* client's selection can round-trip into the photographer's catalogue through
* the XMP `xmp:Label` field (services/xmpGenerator.js) with no remapping.
*
* Mirrored in frontend/src/services/feedback.service.ts (COLOR_LABELS and the
* keymaps) — update both together.
*/
/** Canonical stored values. Lowercase; the XMP layer capitalises. */
const COLOR_LABELS = ['red', 'yellow', 'green', 'blue', 'purple'];
/**
* Lightroom / XMP spelling for each label. `xmp:Label` is a free-text field,
* but Lightroom only lights up its colour swatch for these exact strings.
*/
const COLOR_LABEL_TO_XMP = {
red: 'Red',
yellow: 'Yellow',
green: 'Green',
blue: 'Blue',
purple: 'Purple',
};
/**
* Tie-break order when several guests labelled the same photo differently and
* the export has to pick one. Green first: in the proofing workflow this
* feature exists for, green is "1st choice" — the pick that must survive.
*/
const COLOR_LABEL_PRIORITY = ['green', 'yellow', 'red', 'blue', 'purple'];
/**
* Lightbox keyboard schemes. Both are data, not code, so the gallery lightbox,
* the admin viewer and the settings preview can never drift.
*
* - 'colors' the scheme requested in discussion #1027: three keys, no
* Lightroom knowledge needed. 1 = 1st choice, 2 = 2nd choice,
* 3 = rejected.
* - 'lightroom' Lightroom's own defaults: 1-5 set the star rating, 6-9 set
* red/yellow/green/blue. Lightroom has no default shortcut for
* purple, and neither do we — it stays click-only.
*
* In both schemes pressing the same key again clears the value, and '0'
* clears (the rating in 'lightroom', the colour in 'colors').
*/
const KEYBIND_SCHEMES = {
colors: {
colors: { 1: 'green', 2: 'yellow', 3: 'red' },
ratings: {},
},
lightroom: {
colors: { 6: 'red', 7: 'yellow', 8: 'green', 9: 'blue' },
ratings: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5 },
},
};
function isValidColorLabel(value) {
return COLOR_LABELS.includes(value);
}
/**
* Pick the single colour that represents a photo when several guests have
* labelled it — most-labelled wins, ties broken by COLOR_LABEL_PRIORITY.
*
* @param {Object} counts - e.g. { green: 2, red: 1 }
* @returns {string|null}
*/
function dominantColorLabel(counts) {
if (!counts) return null;
let best = null;
let bestCount = 0;
for (const color of COLOR_LABEL_PRIORITY) {
const count = Number(counts[color]) || 0;
if (count > bestCount) {
best = color;
bestCount = count;
}
}
return best;
}
module.exports = {
COLOR_LABELS,
COLOR_LABEL_TO_XMP,
COLOR_LABEL_PRIORITY,
KEYBIND_SCHEMES,
isValidColorLabel,
dominantColorLabel,
};
+20 -14
View File
@@ -25,6 +25,24 @@ function generateGuestIdentifier(req) {
.digest('hex'); .digest('hex');
} }
/**
* Per-action-type rate limits, in one place — the happy path and the
* error path used to keep separate copies, and the error copy silently
* missed every action type added after it was written.
*/
const DEFAULT_RATE_LIMITS = {
rating: { max: 100, window: 3600 }, // 100 ratings per hour
comment: { max: 20, window: 3600 }, // 20 comments per hour
like: { max: 200, window: 3600 }, // 200 likes per hour
favorite: { max: 100, window: 3600 }, // 100 favorites per hour
reaction: { max: 200, window: 3600 }, // reactions churn like likes (#839)
// Colour labels (#1044) are the keyboard-driven proofing path: a client
// works through a 500-photo shoot pressing 1/2/3, and changing their mind
// costs a second request. A likes-sized 200/h cap would lock them out
// mid-session, so this one is deliberately generous.
color_label: { max: 2000, window: 3600 }
};
/** /**
* Get rate limit settings from app_settings * Get rate limit settings from app_settings
*/ */
@@ -37,13 +55,7 @@ async function getRateLimitSettings() {
// Defaults FIRST, stored values override: persisted rows predate newer // Defaults FIRST, stored values override: persisted rows predate newer
// action types (`reaction`, #839) — returning the stored object alone // action types (`reaction`, #839) — returning the stored object alone
// would silently drop their intended defaults to the generic 100/h. // would silently drop their intended defaults to the generic 100/h.
const defaults = { const defaults = { ...DEFAULT_RATE_LIMITS };
rating: { max: 100, window: 3600 }, // 100 ratings per hour
comment: { max: 20, window: 3600 }, // 20 comments per hour
like: { max: 200, window: 3600 }, // 200 likes per hour
favorite: { max: 100, window: 3600 }, // 100 favorites per hour
reaction: { max: 200, window: 3600 } // reactions churn like likes (#839)
};
if (settings && settings.setting_value) { if (settings && settings.setting_value) {
// setting_value is already a JSON object in PostgreSQL // setting_value is already a JSON object in PostgreSQL
@@ -57,13 +69,7 @@ async function getRateLimitSettings() {
} catch (error) { } catch (error) {
logger.error('Error getting rate limit settings:', error); logger.error('Error getting rate limit settings:', error);
// Return defaults on error // Return defaults on error
return { return { ...DEFAULT_RATE_LIMITS };
rating: { max: 100, window: 3600 },
comment: { max: 20, window: 3600 },
like: { max: 200, window: 3600 },
favorite: { max: 100, window: 3600 },
reaction: { max: 200, window: 3600 }
};
} }
} }
+46 -11
View File
@@ -28,6 +28,7 @@ const { getAppSetting } = require('../../utils/appSettings');
const { clampIntOrUndefined } = require('../../utils/numericHelpers'); const { clampIntOrUndefined } = require('../../utils/numericHelpers');
const { getFrontendBaseUrl, getAbsoluteFrontendUrl } = require('../../utils/frontendUrl'); const { getFrontendBaseUrl, getAbsoluteFrontendUrl } = require('../../utils/frontendUrl');
const downloadZipService = require('../../services/downloadZipService'); const downloadZipService = require('../../services/downloadZipService');
const { resolveEventFeedbackDefaults, applyFeedbackDefaults, KEYBIND_MODES } = require('../../services/feedbackDefaults');
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers'); const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
module.exports = (router) => { module.exports = (router) => {
@@ -93,6 +94,15 @@ module.exports = (router) => {
// #328 follow-up: per-event opt-in for presigned-URL "Download All". // #328 follow-up: per-event opt-in for presigned-URL "Download All".
// Bypasses watermarks; admin must enable knowingly. // Bypasses watermarks; admin must enable knowingly.
body('allow_presigned_download').optional().isBoolean(), body('allow_presigned_download').optional().isBoolean(),
// Feedback sub-toggles (#1044). Optional: omitting them inherits the
// global Settings > Events defaults.
body('allow_ratings').optional().isBoolean(),
body('allow_likes').optional().isBoolean(),
body('allow_comments').optional().isBoolean(),
body('allow_favorites').optional().isBoolean(),
body('allow_reactions').optional().isBoolean(),
body('allow_color_labels').optional().isBoolean(),
body('keybind_mode').optional().isIn(KEYBIND_MODES),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings // Hero logo settings
body('hero_logo_visible').optional({ nullable: true }).isBoolean(), body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
@@ -167,13 +177,20 @@ module.exports = (router) => {
watermark_text = null, watermark_text = null,
allow_presigned_download = false, allow_presigned_download = false,
require_password: requirePasswordInput, require_password: requirePasswordInput,
// Feedback settings // Feedback settings. The allow_* sub-toggles deliberately have NO
// destructuring defaults: `undefined` means "the caller didn't say",
// which inherits the global Settings > Events default (#1044). The
// admin create form posts explicit values (it seeds its own panel
// from the same globals), so inheritance here is what covers the v1
// API and any other caller that omits them.
feedback_enabled: feedbackEnabledInput, feedback_enabled: feedbackEnabledInput,
allow_ratings = true, allow_ratings: allowRatingsInput,
allow_likes = true, allow_likes: allowLikesInput,
allow_comments = true, allow_comments: allowCommentsInput,
allow_favorites = true, allow_favorites: allowFavoritesInput,
allow_reactions = true, allow_reactions: allowReactionsInput,
allow_color_labels: allowColorLabelsInput,
keybind_mode: keybindModeInput,
require_name_email = false, require_name_email = false,
moderate_comments = true, moderate_comments = true,
show_feedback_to_guests = true, show_feedback_to_guests = true,
@@ -253,6 +270,18 @@ module.exports = (router) => {
} }
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback); const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
// Sub-toggle defaults from the global Settings > Events values (#1044).
// One batched read; an explicitly-sent body value still wins.
const feedbackDefaults = applyFeedbackDefaults({
allow_ratings: allowRatingsInput,
allow_likes: allowLikesInput,
allow_comments: allowCommentsInput,
allow_favorites: allowFavoritesInput,
allow_reactions: allowReactionsInput,
allow_color_labels: allowColorLabelsInput,
keybind_mode: keybindModeInput,
}, await resolveEventFeedbackDefaults());
// Debug logging // Debug logging
logger.debug('Download control values', { logger.debug('Download control values', {
allow_downloads, allow_downloads,
@@ -510,11 +539,13 @@ module.exports = (router) => {
await db('event_feedback_settings').insert({ await db('event_feedback_settings').insert({
event_id: eventId, event_id: eventId,
feedback_enabled: formatBoolean(feedback_enabled), feedback_enabled: formatBoolean(feedback_enabled),
allow_ratings: formatBoolean(allow_ratings), allow_ratings: formatBoolean(feedbackDefaults.allow_ratings),
allow_likes: formatBoolean(allow_likes), allow_likes: formatBoolean(feedbackDefaults.allow_likes),
allow_comments: formatBoolean(allow_comments), allow_comments: formatBoolean(feedbackDefaults.allow_comments),
allow_favorites: formatBoolean(allow_favorites), allow_favorites: formatBoolean(feedbackDefaults.allow_favorites),
allow_reactions: formatBoolean(allow_reactions), allow_reactions: formatBoolean(feedbackDefaults.allow_reactions),
allow_color_labels: formatBoolean(feedbackDefaults.allow_color_labels),
keybind_mode: feedbackDefaults.keybind_mode,
require_name_email: formatBoolean(require_name_email), require_name_email: formatBoolean(require_name_email),
moderate_comments: formatBoolean(moderate_comments), moderate_comments: formatBoolean(moderate_comments),
show_feedback_to_guests: formatBoolean(show_feedback_to_guests), show_feedback_to_guests: formatBoolean(show_feedback_to_guests),
@@ -1157,6 +1188,10 @@ module.exports = (router) => {
allow_comments: sourceFeedback.allow_comments, allow_comments: sourceFeedback.allow_comments,
allow_favorites: sourceFeedback.allow_favorites, allow_favorites: sourceFeedback.allow_favorites,
allow_reactions: sourceFeedback.allow_reactions, allow_reactions: sourceFeedback.allow_reactions,
// A clone copies the SOURCE event, so these come from the source
// row rather than the global defaults (#1044).
allow_color_labels: sourceFeedback.allow_color_labels,
keybind_mode: sourceFeedback.keybind_mode,
require_name_email: sourceFeedback.require_name_email, require_name_email: sourceFeedback.require_name_email,
moderate_comments: sourceFeedback.moderate_comments, moderate_comments: sourceFeedback.moderate_comments,
show_feedback_to_guests: sourceFeedback.show_feedback_to_guests, show_feedback_to_guests: sourceFeedback.show_feedback_to_guests,
+2 -1
View File
@@ -262,6 +262,7 @@ router.get('/events/:eventId/feedback-analytics',
total_comments: Number(summaryData.stats?.total_comments) || 0, total_comments: Number(summaryData.stats?.total_comments) || 0,
total_favorites: Number(summaryData.stats?.total_favorites) || 0, total_favorites: Number(summaryData.stats?.total_favorites) || 0,
total_reactions: Number(summaryData.stats?.total_reactions) || 0, total_reactions: Number(summaryData.stats?.total_reactions) || 0,
total_color_labels: Number(summaryData.stats?.total_color_labels) || 0,
}; };
const summary = { const summary = {
average_rating: parseFloat(avgRatingResult?.average_rating || 0), average_rating: parseFloat(avgRatingResult?.average_rating || 0),
@@ -269,7 +270,7 @@ router.get('/events/:eventId/feedback-analytics',
pending_moderation: Number(pendingModeration?.count) || 0, pending_moderation: Number(pendingModeration?.count) || 0,
total_feedback: counts.total_ratings + counts.total_likes + total_feedback: counts.total_ratings + counts.total_likes +
counts.total_comments + counts.total_favorites + counts.total_comments + counts.total_favorites +
counts.total_reactions counts.total_reactions + counts.total_color_labels
}; };
// Get top-rated photos // Get top-rated photos
+6
View File
@@ -83,6 +83,7 @@ router.get(
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'comment\' THEN 1 END) AS comments'), db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'comment\' THEN 1 END) AS comments'),
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'rating\' THEN 1 END) AS ratings'), db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'rating\' THEN 1 END) AS ratings'),
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'reaction\' THEN 1 END) AS reactions'), db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'reaction\' THEN 1 END) AS reactions'),
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'color_label\' THEN 1 END) AS color_labels'),
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos') db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
) )
.orderBy('gallery_guests.created_at', 'desc'); .orderBy('gallery_guests.created_at', 'desc');
@@ -410,6 +411,7 @@ router.get(
'photo_feedback.rating', 'photo_feedback.rating',
'photo_feedback.comment_text', 'photo_feedback.comment_text',
'photo_feedback.reaction', 'photo_feedback.reaction',
'photo_feedback.color_label',
'photo_feedback.created_at', 'photo_feedback.created_at',
'photos.id as photo_id', 'photos.id as photo_id',
'photos.filename', 'photos.filename',
@@ -433,6 +435,7 @@ router.get(
rated: [], rated: [],
commented: [], commented: [],
reacted: [], reacted: [],
labeled: [],
}; };
for (const row of feedback) { for (const row of feedback) {
if (row.feedback_type === 'like') { if (row.feedback_type === 'like') {
@@ -449,6 +452,8 @@ router.get(
}); });
} else if (row.feedback_type === 'reaction') { } else if (row.feedback_type === 'reaction') {
selections.reacted.push({ photo: photoFor(row), reaction: row.reaction }); selections.reacted.push({ photo: photoFor(row), reaction: row.reaction });
} else if (row.feedback_type === 'color_label') {
selections.labeled.push({ photo: photoFor(row), color_label: row.color_label });
} }
} }
@@ -461,6 +466,7 @@ router.get(
comments: selections.commented.length, comments: selections.commented.length,
ratings: selections.rated.length, ratings: selections.rated.length,
reactions: selections.reacted.length, reactions: selections.reacted.length,
color_labels: selections.labeled.length,
}, },
}, },
selections, selections,
+22 -3
View File
@@ -13,6 +13,7 @@ const { requireEventOwnership } = require('../middleware/ownership');
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder'); const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
const { getPagination } = require('../utils/routeHelpers'); const { getPagination } = require('../utils/routeHelpers');
const { PhotoExportService } = require('../services/photoExportService'); const { PhotoExportService } = require('../services/photoExportService');
const photoAdminMarksService = require('../services/photoAdminMarksService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const exportService = new PhotoExportService(); const exportService = new PhotoExportService();
@@ -29,6 +30,8 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), re
query('has_favorites').optional().isBoolean(), query('has_favorites').optional().isBoolean(),
query('min_favorites').optional().isInt({ min: 0 }), query('min_favorites').optional().isInt({ min: 0 }),
query('has_comments').optional().isBoolean(), query('has_comments').optional().isBoolean(),
query('color_labels').optional().isString(),
query('my_color_labels').optional().isString(),
query('category_id').optional().isInt(), query('category_id').optional().isInt(),
query('logic').optional().isIn(['AND', 'OR']), query('logic').optional().isIn(['AND', 'OR']),
query('sort').optional().isIn(['rating', 'likes', 'favorites', 'date', 'filename']), query('sort').optional().isIn(['rating', 'likes', 'favorites', 'date', 'filename']),
@@ -62,6 +65,9 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), re
has_favorites: req.query.has_favorites, has_favorites: req.query.has_favorites,
min_favorites: req.query.min_favorites ? parseInt(req.query.min_favorites) : undefined, min_favorites: req.query.min_favorites ? parseInt(req.query.min_favorites) : undefined,
has_comments: req.query.has_comments, has_comments: req.query.has_comments,
color_labels: req.query.color_labels,
my_color_labels: req.query.my_color_labels,
admin_id: req.admin.id,
category_id: req.query.category_id ? parseInt(req.query.category_id) : undefined, category_id: req.query.category_id ? parseInt(req.query.category_id) : undefined,
logic: req.query.logic || 'AND' logic: req.query.logic || 'AND'
}; };
@@ -84,6 +90,7 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), re
'photos.like_count', 'photos.like_count',
'photos.favorite_count', 'photos.favorite_count',
'photos.comment_count', 'photos.comment_count',
'photos.color_label_count',
'photos.width', 'photos.width',
'photos.height', 'photos.height',
'photos.uploaded_at', 'photos.uploaded_at',
@@ -142,9 +149,15 @@ router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view
PhotoFilterBuilder.getSummary(db, eventId) PhotoFilterBuilder.getSummary(db, eventId)
); );
// Per-colour counts for the caller's OWN marks (#1044 follow-up), so the
// "My marks" chips can show numbers the way the client-selection chips do.
const myColorLabelCounts = await withRetry(() =>
photoAdminMarksService.getEventMarkColorCounts(eventId, req.admin.id)
);
res.json({ res.json({
success: true, success: true,
data: summary data: { ...summary, myColorLabelCounts }
}); });
} catch (error) { } catch (error) {
logger.error('Filter summary error:', error); logger.error('Filter summary error:', error);
@@ -161,6 +174,7 @@ router.post('/:eventId/export', adminAuth, requirePermission('photos.download'),
body('photo_ids.*').optional().isInt(), body('photo_ids.*').optional().isInt(),
body('filter').optional().isObject(), body('filter').optional().isObject(),
body('format').isIn(['txt', 'csv', 'xmp', 'json']), body('format').isIn(['txt', 'csv', 'xmp', 'json']),
body('options.mark_source').optional().isIn(['client', 'mine']),
body('options').optional().isObject() body('options').optional().isObject()
], async (req, res) => { ], async (req, res) => {
try { try {
@@ -189,13 +203,18 @@ router.post('/:eventId/export', adminAuth, requirePermission('photos.download'),
db('photos').select('id'), db('photos').select('id'),
eventId eventId
); );
filterBuilder.applyFilters(filter); // admin_id comes from the session, so a my-marks filter in the body can
// only ever mean the caller's own marks.
filterBuilder.applyFilters({ ...filter, admin_id: req.admin.id });
const filteredPhotos = await withRetry(() => filterBuilder.getQuery()); const filteredPhotos = await withRetry(() => filterBuilder.getQuery());
photoIds = filteredPhotos.map(p => p.id); photoIds = filteredPhotos.map(p => p.id);
} }
// Export photos // Export photos
const result = await exportService.exportPhotos(eventId, photoIds, format, options); const result = await exportService.exportPhotos(eventId, photoIds, format, {
...options,
admin_id: req.admin.id,
});
if (result.type === 'stream') { if (result.type === 'stream') {
res.setHeader('Content-Type', result.contentType); res.setHeader('Content-Type', result.contentType);
+114 -1
View File
@@ -13,6 +13,9 @@ const {
pickRawDownloadName, pickRawDownloadName,
} = require('../services/downloadFilenameService'); } = require('../services/downloadFilenameService');
const { escapeLikePattern } = require('../utils/sqlSecurity'); const { escapeLikePattern } = require('../utils/sqlSecurity');
const { COLOR_LABELS, dominantColorLabel } = require('../constants/colorLabels');
const feedbackService = require('../services/feedbackService');
const photoAdminMarksService = require('../services/photoAdminMarksService');
const { validateUploadedFiles } = require('../middleware/uploadValidation'); const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings'); const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings');
const { processUploadedPhotos } = require('../services/photoProcessor'); const { processUploadedPhotos } = require('../services/photoProcessor');
@@ -724,6 +727,60 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
} }
}); });
// The photographer's own star / colour mark on a photo (#1044 follow-up).
//
// Separate from guest feedback in every sense: its own table, its own
// endpoint, and never surfaced to the gallery. `rating` and `color_label` are
// tri-state — omit a key to leave that half alone, send null to clear it — so
// the lightbox's colour keys and star keys don't wipe each other.
router.put('/:eventId/photos/:photoId/mark', adminAuth, requirePermission('photos.edit'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
// Parse before querying: Postgres errors on `where id = 'abc'` (22P02),
// which would answer 500 for what is really a bad URL. SQLite just fails
// to match, so without this the two engines disagree.
const photoId = parseInt(req.params.photoId, 10);
if (!Number.isInteger(photoId) || photoId < 1) {
return res.status(404).json({ error: 'Photo not found' });
}
// requireEventOwnership proves the caller owns the EVENT; this proves the
// photo is in it, so a photo id from another event can't be marked
// through an event the caller does own.
const photo = await db('photos')
.where({ id: photoId, event_id: eventId })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
const mark = {};
if (Object.prototype.hasOwnProperty.call(req.body, 'rating')) {
mark.rating = req.body.rating === null ? null : req.body.rating;
}
if (Object.prototype.hasOwnProperty.call(req.body, 'color_label')) {
mark.colorLabel = req.body.color_label === null ? null : req.body.color_label;
}
if (Object.keys(mark).length === 0) {
return res.status(400).json({ error: 'Send rating and/or color_label' });
}
const result = await photoAdminMarksService.setMark(
parseInt(eventId, 10), photoId, req.admin.id, mark,
);
res.json({ success: true, mark: result });
} catch (error) {
// Validation errors from the service are the caller's fault, not a 500.
// Keyed on the code, not the message: matching text would couple this
// status to the service's wording.
if (error.code === photoAdminMarksService.INVALID_MARK) {
return res.status(400).json({ error: error.message });
}
errorResponse(res, error, 500, 'Failed to save mark');
}
});
// Update a photo (e.g., change category) // Update a photo (e.g., change category)
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), requireEventOwnership, async (req, res) => { router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), requireEventOwnership, async (req, res) => {
try { try {
@@ -1036,7 +1093,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => { router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
try { try {
const { eventId } = req.params; const { eventId } = req.params;
const { category_id, type, search, sort = 'date', has_likes, has_favorites, has_comments, min_rating } = req.query; const { category_id, type, search, sort = 'date', has_likes, has_favorites, has_comments, min_rating, color_label } = req.query;
const order = ['asc', 'desc'].includes(req.query.order) ? req.query.order : 'desc'; const order = ['asc', 'desc'].includes(req.query.order) ? req.query.order : 'desc';
const logic = req.query.logic === 'OR' ? 'OR' : 'AND'; const logic = req.query.logic === 'OR' ? 'OR' : 'AND';
@@ -1090,6 +1147,41 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
feedbackConditions.push(qb => qb.where('photos.average_rating', '>=', minRatingNum)); feedbackConditions.push(qb => qb.where('photos.average_rating', '>=', minRatingNum));
} }
} }
// Colour-label filter (#1044). Comma-separated colours; unknown values are
// dropped rather than passed to the query. Unlike its siblings this can't
// read a denormalized count column — "any label" and "a GREEN label" are
// different questions — so it runs as an EXISTS over photo_feedback,
// which the migration-180 index covers.
const requestedColorLabels = String(color_label || '')
.split(',')
.map(value => value.trim().toLowerCase())
.filter(value => COLOR_LABELS.includes(value));
if (requestedColorLabels.length > 0) {
feedbackConditions.push(qb => qb.whereExists(function () {
this.select('*')
.from('photo_feedback')
.whereRaw('photo_feedback.photo_id = photos.id')
.where('photo_feedback.feedback_type', 'color_label')
.where('photo_feedback.is_hidden', false)
.whereIn('photo_feedback.color_label', requestedColorLabels);
}));
}
// The same filter against the caller's OWN marks (#1044 follow-up).
// Scoped to req.admin.id: one photographer's triage must not filter by
// another's, even on a shared event.
const requestedMyColorLabels = String(req.query.my_color_label || '')
.split(',')
.map(value => value.trim().toLowerCase())
.filter(value => COLOR_LABELS.includes(value));
if (requestedMyColorLabels.length > 0) {
feedbackConditions.push(qb => qb.whereExists(function () {
this.select('*')
.from('photo_admin_marks')
.whereRaw('photo_admin_marks.photo_id = photos.id')
.where('photo_admin_marks.admin_id', req.admin.id)
.whereIn('photo_admin_marks.color_label', requestedMyColorLabels);
}));
}
if (feedbackConditions.length > 0) { if (feedbackConditions.length > 0) {
if (logic === 'OR') { if (logic === 'OR') {
query = query.where(builder => { query = query.where(builder => {
@@ -1133,6 +1225,20 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
commentMap[c.photo_id] = parseInt(c.comment_count); commentMap[c.photo_id] = parseInt(c.comment_count);
}); });
// Per-colour tallies for the grid badges (#1044) — one grouped query for
// the whole page, same shape as commentMap above.
const colorLabelMap = await feedbackService.getEventColorLabelCounts(
parseInt(eventId, 10),
photos.map(p => p.id),
);
// The caller's own marks for this page (#1044 follow-up).
const myMarks = await photoAdminMarksService.getEventMarks(
parseInt(eventId, 10),
req.admin.id,
photos.map(p => p.id),
);
res.json({ res.json({
photos: photos.map(photo => ({ photos: photos.map(photo => ({
id: photo.id, id: photo.id,
@@ -1159,6 +1265,13 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
comment_count: commentMap[photo.id] || 0, comment_count: commentMap[photo.id] || 0,
like_count: photo.like_count || 0, like_count: photo.like_count || 0,
favorite_count: photo.favorite_count || 0, favorite_count: photo.favorite_count || 0,
color_label_count: photo.color_label_count || 0,
color_labels: colorLabelMap[photo.id] || {},
dominant_color_label: dominantColorLabel(colorLabelMap[photo.id]),
// The requesting admin's own mark — never the whole team's, and never
// shown to guests.
my_rating: myMarks[photo.id]?.rating ?? null,
my_color_label: myMarks[photo.id]?.color_label ?? null,
// Engagement counters (#895 follow-up): the grid reads these, but // Engagement counters (#895 follow-up): the grid reads these, but
// this explicit mapper never included them — so the Engagement // this explicit mapper never included them — so the Engagement
// column showed 0 regardless of what the DB counted. This, not // column showed 0 regardless of what the DB counted. This, not
+55 -1
View File
@@ -31,6 +31,7 @@ const { verifyGalleryAccess, denySlideshowToken, isAdminPreview } = require('../
const withPreview = (req, url) => (req.isAdminPreview ? `${url}${url.includes('?') ? '&' : '?'}admin_preview=1` : url); const withPreview = (req, url) => (req.isAdminPreview ? `${url}${url.includes('?') ? '&' : '?'}admin_preview=1` : url);
const { resolveGuest } = require('../middleware/guestAuth'); const { resolveGuest } = require('../middleware/guestAuth');
const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit'); const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const { COLOR_LABELS } = require('../constants/colorLabels');
const secureImageService = require('../services/secureImageService'); const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { pipeStreamToResponse } = require('../utils/streamResponse'); const { pipeStreamToResponse } = require('../utils/streamResponse');
@@ -721,10 +722,11 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
}; };
let guestFeedbackByType = null; let guestFeedbackByType = null;
let guestColorLabels = null;
if (guest_id) { if (guest_id) {
const guestFeedbackRows = await db('photo_feedback') const guestFeedbackRows = await db('photo_feedback')
.where({ event_id: req.event.id, guest_identifier: guest_id }) .where({ event_id: req.event.id, guest_identifier: guest_id })
.select('photo_id', 'feedback_type'); .select('photo_id', 'feedback_type', 'color_label');
guestFeedbackByType = guestFeedbackRows.reduce((acc, row) => { guestFeedbackByType = guestFeedbackRows.reduce((acc, row) => {
if (!acc[row.feedback_type]) { if (!acc[row.feedback_type]) {
@@ -733,6 +735,15 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
acc[row.feedback_type].add(row.photo_id); acc[row.feedback_type].add(row.photo_id);
return acc; return acc;
}, {}); }, {});
// Colour filters are per-COLOUR, not just per-type (#1044) — "show
// me my greens" needs the value, which the type map above discards.
guestColorLabels = guestFeedbackRows.reduce((acc, row) => {
if (row.feedback_type !== 'color_label' || !row.color_label) return acc;
if (!acc[row.color_label]) acc[row.color_label] = new Set();
acc[row.color_label].add(row.photo_id);
return acc;
}, {});
} }
const includeGuestMatches = (type) => { const includeGuestMatches = (type) => {
@@ -757,6 +768,23 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
includeBy(photo => (photo.average_rating || 0) > 0); includeBy(photo => (photo.average_rating || 0) > 0);
} }
// Colour-label filters (#1044), one token per colour: `color:green`.
// Same OR-of-guest-and-aggregate shape as the sibling tokens above:
// the guest's own labels of that colour, plus anyone's. (The public
// gallery narrows to "my greens" client-side from `my_color_label`,
// which is per-viewer by construction.)
const requestedColors = COLOR_LABELS.filter(color => filterTokens.has(`color:${color}`));
if (requestedColors.length > 0) {
for (const color of requestedColors) {
guestColorLabels?.[color]?.forEach(id => include.add(id));
}
const colorRows = await db('photo_feedback')
.where({ event_id: req.event.id, feedback_type: 'color_label', is_hidden: false })
.whereIn('color_label', requestedColors)
.select('photo_id');
colorRows.forEach(row => include.add(row.photo_id));
}
if (filterTokens.has('commented')) { if (filterTokens.has('commented')) {
includeGuestMatches('comment'); includeGuestMatches('comment');
const commentedRows = await db('photo_feedback') const commentedRows = await db('photo_feedback')
@@ -813,6 +841,26 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
likedRows.forEach(row => likedPhotoIds.add(row.photo_id)); likedRows.forEach(row => likedPhotoIds.add(row.photo_id));
} }
// Per-viewer colour label (#1044), same identity resolution as the likes
// above. NOT gated on showFeedbackToGuests: a guest's own label is their
// own selection, not shared aggregate data, and hiding it would blank the
// grid badges on every refresh in a gallery with sharing switched off.
const myColorLabelByPhoto = {};
if (photos.length > 0) {
const colorQuery = db('photo_feedback')
.where({ event_id: req.event.id, feedback_type: 'color_label' })
.whereIn('photo_id', photos.map(p => p.id));
if (req.guest?.id) {
colorQuery.where('guest_id', req.guest.id);
} else {
colorQuery.where('guest_identifier', generateGuestIdentifier(req));
}
const colorRows = await colorQuery.select('photo_id', 'color_label');
colorRows.forEach(row => {
if (row.color_label) myColorLabelByPhoto[row.photo_id] = row.color_label;
});
}
// People in each photo (#1074). Two independent gates: the feature must // People in each photo (#1074). Two independent gates: the feature must
// be on for this event AND, for a plain guest, the photographer must have // be on for this event AND, for a plain guest, the photographer must have
// left the strip visible. A client (PIN access) is the photographer's own // left the strip visible. A client (PIN access) is the photographer's own
@@ -1087,6 +1135,12 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// grid seed its lifted likedPhotoIds correctly on hard refresh. // grid seed its lifted likedPhotoIds correctly on hard refresh.
is_liked: showFeedbackToGuests ? likedPhotoIds.has(photo.id) : false, is_liked: showFeedbackToGuests ? likedPhotoIds.has(photo.id) : false,
favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0, favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0,
// Colour labels (#1044). The COUNT is aggregate data and follows
// show_feedback_to_guests like its siblings; the viewer's OWN label
// is not aggregate and must survive with sharing off, otherwise the
// grid badge disappears on refresh for the very guest who set it.
color_label_count: showFeedbackToGuests ? (photo.color_label_count || 0) : 0,
my_color_label: myColorLabelByPhoto[photo.id] || null,
// People in this photo (#1074). Empty array when the feature is // People in this photo (#1074). Empty array when the feature is
// off for this event or hidden from guests, so the frontend has // off for this event or hidden from guests, so the frontend has
// one shape to handle. Riding along on this payload is what keeps // one shape to handle. Riding along on this payload is what keeps
+17 -3
View File
@@ -33,6 +33,9 @@ router.get('/:slug/feedback-settings',
allow_comments: Boolean(settings.allow_comments), allow_comments: Boolean(settings.allow_comments),
allow_favorites: Boolean(settings.allow_favorites), allow_favorites: Boolean(settings.allow_favorites),
allow_reactions: Boolean(settings.allow_reactions), allow_reactions: Boolean(settings.allow_reactions),
allow_color_labels: Boolean(settings.allow_color_labels),
// Which lightbox shortcut scheme this gallery uses (#1044).
keybind_mode: settings.keybind_mode || 'colors',
require_name_email: Boolean(settings.require_name_email), require_name_email: Boolean(settings.require_name_email),
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests), show_feedback_to_guests: Boolean(settings.show_feedback_to_guests),
identity_mode: settings.identity_mode || 'simple', identity_mode: settings.identity_mode || 'simple',
@@ -125,6 +128,7 @@ router.get('/:slug/photos/:photoId/feedback',
// Gated like the per-emoji map below — aggregate reaction data is // Gated like the per-emoji map below — aggregate reaction data is
// a new surface, kept fully hidden while sharing is off. // a new surface, kept fully hidden while sharing is off.
reaction_count: settings.show_feedback_to_guests ? (photo.reaction_count || 0) : 0, reaction_count: settings.show_feedback_to_guests ? (photo.reaction_count || 0) : 0,
color_label_count: settings.show_feedback_to_guests ? (photo.color_label_count || 0) : 0,
comment_count: await db('photo_feedback') comment_count: await db('photo_feedback')
.where({ .where({
photo_id: photoId, photo_id: photoId,
@@ -142,11 +146,17 @@ router.get('/:slug/photos/:photoId/feedback',
reactions: settings.show_feedback_to_guests reactions: settings.show_feedback_to_guests
? await feedbackService.getPhotoReactionCounts(photoId) ? await feedbackService.getPhotoReactionCounts(photoId)
: {}, : {},
// Per-colour tallies (#1044), gated exactly like `reactions` above:
// with sharing off the guest sees only their own label.
color_labels: settings.show_feedback_to_guests
? await feedbackService.getPhotoColorLabelCounts(photoId)
: {},
my_feedback: { my_feedback: {
rating: guestFeedback.find(f => f.feedback_type === 'rating')?.rating, rating: guestFeedback.find(f => f.feedback_type === 'rating')?.rating,
liked: !!guestFeedback.find(f => f.feedback_type === 'like'), liked: !!guestFeedback.find(f => f.feedback_type === 'like'),
favorited: !!guestFeedback.find(f => f.feedback_type === 'favorite'), favorited: !!guestFeedback.find(f => f.feedback_type === 'favorite'),
reaction: guestFeedback.find(f => f.feedback_type === 'reaction')?.reaction || null reaction: guestFeedback.find(f => f.feedback_type === 'reaction')?.reaction || null,
color_label: guestFeedback.find(f => f.feedback_type === 'color_label')?.color_label || null
} }
}); });
} catch (error) { } catch (error) {
@@ -199,7 +209,8 @@ router.post('/:slug/photos/:photoId/feedback',
like: settings.allow_likes, like: settings.allow_likes,
comment: settings.allow_comments, comment: settings.allow_comments,
favorite: settings.allow_favorites, favorite: settings.allow_favorites,
reaction: settings.allow_reactions reaction: settings.allow_reactions,
color_label: settings.allow_color_labels
}; };
if (!typeAllowed[feedbackType]) { if (!typeAllowed[feedbackType]) {
@@ -246,6 +257,7 @@ router.post('/:slug/photos/:photoId/feedback',
rating: req.body.rating, rating: req.body.rating,
comment_text: req.body.comment_text, comment_text: req.body.comment_text,
reaction: req.body.reaction, reaction: req.body.reaction,
color_label: req.body.color_label,
guest_name: req.guest?.name ?? req.body.guest_name, guest_name: req.guest?.name ?? req.body.guest_name,
guest_email: req.guest?.email ?? req.body.guest_email, guest_email: req.guest?.email ?? req.body.guest_email,
guest_id: req.guest?.id ?? null, guest_id: req.guest?.id ?? null,
@@ -372,7 +384,9 @@ router.get('/:slug/feedback-summary',
allow_likes: settings.allow_likes, allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments, allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites, allow_favorites: settings.allow_favorites,
allow_reactions: settings.allow_reactions allow_reactions: settings.allow_reactions,
allow_color_labels: settings.allow_color_labels,
keybind_mode: settings.keybind_mode || 'colors'
}, },
summary: guestSummary summary: guestSummary
}); });
+20
View File
@@ -18,6 +18,16 @@ router.get('/', async (req, res) => {
'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai', 'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai',
'event_default_require_password', 'event_default_require_password',
'event_default_feedback_enabled', 'event_default_feedback_enabled',
// Per-type feedback defaults (#1044) — the create form seeds
// its feedback panel from these, the same way it seeds the
// master toggle above.
'event_default_allow_ratings',
'event_default_allow_likes',
'event_default_allow_favorites',
'event_default_allow_comments',
'event_default_allow_reactions',
'event_default_allow_color_labels',
'event_default_keybind_mode',
'gallery_show_filter_bar', 'gallery_show_filter_bar',
'event_phone_field_enabled', 'event_phone_field_enabled',
// #613 — guest upload UI needs to know the per-batch file // #613 — guest upload UI needs to know the per-batch file
@@ -206,6 +216,16 @@ router.get('/', async (req, res) => {
// Defaults to false (matches the prior hard-coded form default), so // Defaults to false (matches the prior hard-coded form default), so
// existing installs see no behaviour change until an admin flips it. // existing installs see no behaviour change until an admin flips it.
event_default_feedback_enabled: settingsObject.event_default_feedback_enabled === true, event_default_feedback_enabled: settingsObject.event_default_feedback_enabled === true,
// Per-type feedback defaults (#1044). The fallbacks mirror
// FEEDBACK_TOGGLES in services/feedbackDefaults.js — the backend is
// still the authority; these only pre-fill the create form.
event_default_allow_ratings: settingsObject.event_default_allow_ratings !== false,
event_default_allow_likes: settingsObject.event_default_allow_likes !== false,
event_default_allow_favorites: settingsObject.event_default_allow_favorites !== false,
event_default_allow_comments: settingsObject.event_default_allow_comments !== false,
event_default_allow_reactions: settingsObject.event_default_allow_reactions !== false,
event_default_allow_color_labels: settingsObject.event_default_allow_color_labels === true,
event_default_keybind_mode: settingsObject.event_default_keybind_mode === 'lightroom' ? 'lightroom' : 'colors',
// Phone-number field on events is opt-in (#322). // Phone-number field on events is opt-in (#322).
event_phone_field_enabled: settingsObject.event_phone_field_enabled === true, event_phone_field_enabled: settingsObject.event_phone_field_enabled === true,
// Whether to show the search/sort filter bar in public galleries (default: true) // Whether to show the search/sort filter bar in public galleries (default: true)
@@ -172,20 +172,25 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
// 2. branding probe (whereIn → select) // 2. branding probe (whereIn → select)
// 3. slug probe // 3. slug probe
// 4. events insert // 4. events insert
// 5. event_feedback_settings insert // 5. feedback sub-toggle defaults probe (whereIn → select, #1044)
// 6. event_feedback_settings insert
const devtoolsChain = buildChain({ firstResult: null }); const devtoolsChain = buildChain({ firstResult: null });
const brandingChain = buildChain({ selectResult: [] }); 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 feedbackDefaultsChain = buildChain({ selectResult: [] });
const feedbackInsertChain = buildChain(); const feedbackInsertChain = buildChain();
db.__setImplementations(devtoolsChain, brandingChain, slugChain, insertChain, feedbackInsertChain); db.__setImplementations(
devtoolsChain, brandingChain, slugChain, insertChain,
feedbackDefaultsChain, 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);
expect(db).toHaveBeenNthCalledWith(5, 'event_feedback_settings'); expect(db).toHaveBeenNthCalledWith(6, '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 });
@@ -197,6 +202,12 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
expect(Boolean(feedbackRow.allow_likes)).toBe(true); expect(Boolean(feedbackRow.allow_likes)).toBe(true);
expect(Boolean(feedbackRow.allow_comments)).toBe(true); expect(Boolean(feedbackRow.allow_comments)).toBe(true);
expect(Boolean(feedbackRow.allow_favorites)).toBe(true); expect(Boolean(feedbackRow.allow_favorites)).toBe(true);
// #1044: this insert used to omit allow_reactions entirely, so v1-created
// events only got reactions by accident of the column default.
expect(Boolean(feedbackRow.allow_reactions)).toBe(true);
// Colour labels are opt-in, so they stay off until the global is flipped.
expect(Boolean(feedbackRow.allow_color_labels)).toBe(false);
expect(feedbackRow.keybind_mode).toBe('colors');
expect(Boolean(feedbackRow.require_name_email)).toBe(false); expect(Boolean(feedbackRow.require_name_email)).toBe(false);
expect(Boolean(feedbackRow.moderate_comments)).toBe(true); expect(Boolean(feedbackRow.moderate_comments)).toBe(true);
expect(Boolean(feedbackRow.show_feedback_to_guests)).toBe(true); expect(Boolean(feedbackRow.show_feedback_to_guests)).toBe(true);
@@ -205,7 +216,8 @@ 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 () => {
// Feedback probe returns serialized "true" → fallback kicks in and // Feedback probe returns serialized "true" → fallback kicks in and
// the feedback insert runs. Sequence: feedback probe, devtools probe, // the feedback insert runs. Sequence: feedback probe, devtools probe,
// branding probe, slug, insert, feedback insert (6 calls total). // branding probe, slug, insert, sub-toggle defaults probe (#1044),
// feedback insert (7 calls total).
const feedbackProbe = buildChain({ const feedbackProbe = buildChain({
firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' }, firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' },
}); });
@@ -213,9 +225,11 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
const brandingChain = buildChain({ selectResult: [] }); 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 feedbackDefaultsChain = buildChain({ selectResult: [] });
const feedbackInsertChain = buildChain(); const feedbackInsertChain = buildChain();
db.__setImplementations( db.__setImplementations(
feedbackProbe, devtoolsChain, brandingChain, slugChain, insertChain, feedbackInsertChain feedbackProbe, devtoolsChain, brandingChain, slugChain, insertChain,
feedbackDefaultsChain, feedbackInsertChain,
); );
await request(buildApp()) await request(buildApp())
@@ -223,7 +237,7 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
.send(BASE_BODY) .send(BASE_BODY)
.expect(201); .expect(201);
expect(db).toHaveBeenNthCalledWith(6, 'event_feedback_settings'); expect(db).toHaveBeenNthCalledWith(7, 'event_feedback_settings');
expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1); expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1);
}); });
+13 -7
View File
@@ -29,6 +29,7 @@ const { requireEventOwnership, scopeEventsQuery } = require('../../middleware/ow
// requirePermission gates supply the missing half; they key on req.admin.id, // requirePermission gates supply the missing half; they key on req.admin.id,
// which apiTokenAuth populates. // which apiTokenAuth populates.
const { requirePermission } = require('../../middleware/permissions'); const { requirePermission } = require('../../middleware/permissions');
const { resolveEventFeedbackDefaults } = require('../../services/feedbackDefaults');
const { buildShareLinkVariants } = require('../../services/shareLinkService'); const { buildShareLinkVariants } = require('../../services/shareLinkService');
const { generateThumbnail } = require('../../services/imageProcessor'); const { generateThumbnail } = require('../../services/imageProcessor');
const logger = require('../../utils/logger'); const logger = require('../../utils/logger');
@@ -303,17 +304,22 @@ router.post(
const id = insertResult[0]?.id || insertResult[0]; const id = insertResult[0]?.id || insertResult[0];
// Issue #550 — mirror adminEvents.js: create event_feedback_settings // Issue #550 — mirror adminEvents.js: create event_feedback_settings
// row when feedback is enabled, so the gallery actually shows // row when feedback is enabled, so the gallery actually shows feedback
// feedback UI. Sub-flags default to the same values the admin form // UI. The sub-flags come from the shared global defaults (#1044) rather
// ships with (everything on except require_name_email). // than a hard-coded list, which is how this path silently shipped
// without allow_reactions for two releases.
if (feedback_enabled) { if (feedback_enabled) {
const feedbackDefaults = await resolveEventFeedbackDefaults();
await db('event_feedback_settings').insert({ await db('event_feedback_settings').insert({
event_id: id, event_id: id,
feedback_enabled: formatBoolean(true), feedback_enabled: formatBoolean(true),
allow_ratings: formatBoolean(true), allow_ratings: formatBoolean(feedbackDefaults.allow_ratings),
allow_likes: formatBoolean(true), allow_likes: formatBoolean(feedbackDefaults.allow_likes),
allow_comments: formatBoolean(true), allow_comments: formatBoolean(feedbackDefaults.allow_comments),
allow_favorites: formatBoolean(true), allow_favorites: formatBoolean(feedbackDefaults.allow_favorites),
allow_reactions: formatBoolean(feedbackDefaults.allow_reactions),
allow_color_labels: formatBoolean(feedbackDefaults.allow_color_labels),
keybind_mode: feedbackDefaults.keybind_mode,
require_name_email: formatBoolean(false), require_name_email: formatBoolean(false),
moderate_comments: formatBoolean(true), moderate_comments: formatBoolean(true),
show_feedback_to_guests: formatBoolean(true), show_feedback_to_guests: formatBoolean(true),
+170
View File
@@ -0,0 +1,170 @@
/**
* Global defaults for the per-event guest-feedback toggles (#1044).
*
* Before this module the creation defaults lived in four places that had
* already drifted apart: the admin create route's destructuring defaults, the
* v1 create route's hard-coded insert (which forgot `allow_reactions`
* entirely), `feedbackService.getEventFeedbackSettings()`'s no-row fallback
* and the migration column defaults. The same install answered "are comments
* on by default?" differently depending on which code path created the event.
*
* Everything now resolves through FEEDBACK_TOGGLES:
*
* app_settings row -> per-event column default -> the built-in fallback
*
* The `event_default_*` keys extend the family `event_default_require_password`
* (#317) and `event_default_feedback_enabled` (#520) already established: they
* are the value a NEW event starts from, not a retroactive master switch.
* Flipping one never changes a gallery that already exists a switch that
* silently strips the rating stars off a gallery a client is mid-way through
* proofing would be a different, much riskier feature.
*
* Adding a seventh feedback type means adding one row to FEEDBACK_TOGGLES
* (plus its column in the migration and its UI) no sweep across the create
* routes.
*/
const { db } = require('../database/db');
const logger = require('../utils/logger');
/**
* Every guest-feedback toggle, in the order the admin UI shows them.
*
* `fallback` is what applies when the admin has never touched the global
* setting. These match the values the admin create route has shipped with, so
* installs that never open Settings > Events keep creating events exactly as
* they did before except `allow_color_labels`, which is new in #1044 and
* opt-in so no existing gallery grows a colour bar on upgrade.
*/
const FEEDBACK_TOGGLES = [
{ column: 'allow_ratings', settingKey: 'event_default_allow_ratings', fallback: true },
{ column: 'allow_likes', settingKey: 'event_default_allow_likes', fallback: true },
{ column: 'allow_favorites', settingKey: 'event_default_allow_favorites', fallback: true },
{ column: 'allow_comments', settingKey: 'event_default_allow_comments', fallback: true },
{ column: 'allow_reactions', settingKey: 'event_default_allow_reactions', fallback: true },
{ column: 'allow_color_labels', settingKey: 'event_default_allow_color_labels', fallback: false },
];
/** Non-boolean feedback defaults that follow the same global -> event flow. */
const KEYBIND_MODES = ['colors', 'lightroom'];
const DEFAULT_KEYBIND_MODE = 'colors';
const KEYBIND_MODE_SETTING_KEY = 'event_default_keybind_mode';
/** Every app_settings key this module owns — for the settings API surface. */
const FEEDBACK_DEFAULT_SETTING_KEYS = [
...FEEDBACK_TOGGLES.map((t) => t.settingKey),
KEYBIND_MODE_SETTING_KEY,
];
/**
* app_settings stores JSON-encoded values, but rows written by older code
* paths (and by SQLite's looser typing) can arrive as raw strings. Accept
* both, and return undefined for anything that isn't recognisably a boolean
* so the caller falls back rather than persisting `null` into a NOT NULL-ish
* boolean column.
*/
function parseBooleanSetting(rawValue) {
let value = rawValue;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch {
const normalized = value.trim().toLowerCase();
if (normalized === 'true') return true;
if (normalized === 'false') return false;
return undefined;
}
}
if (typeof value === 'boolean') return value;
// SQLite hands back 0/1 for booleans written through formatBoolean.
if (value === 1 || value === 0) return value === 1;
return undefined;
}
function parseKeybindModeSetting(rawValue) {
let value = rawValue;
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (typeof parsed === 'string') value = parsed;
} catch {
/* keep raw */
}
}
return KEYBIND_MODES.includes(value) ? value : undefined;
}
/**
* Resolve the global feedback defaults in ONE query (mirrors
* getBrandingDefaults' batched whereIn rather than firing seven `.first()`
* calls on every event creation).
*
* Never throws: a settings-table hiccup must not fail event creation, so the
* built-in fallbacks stand in.
*
* @returns {Promise<{allow_ratings: boolean, allow_likes: boolean,
* allow_favorites: boolean, allow_comments: boolean, allow_reactions: boolean,
* allow_color_labels: boolean, keybind_mode: string}>}
*/
async function resolveEventFeedbackDefaults() {
const defaults = {};
for (const toggle of FEEDBACK_TOGGLES) {
defaults[toggle.column] = toggle.fallback;
}
defaults.keybind_mode = DEFAULT_KEYBIND_MODE;
try {
const rows = await db('app_settings')
.whereIn('setting_key', FEEDBACK_DEFAULT_SETTING_KEYS)
.select('setting_key', 'setting_value');
const bySettingKey = new Map(rows.map((row) => [row.setting_key, row.setting_value]));
for (const toggle of FEEDBACK_TOGGLES) {
if (!bySettingKey.has(toggle.settingKey)) continue;
const parsed = parseBooleanSetting(bySettingKey.get(toggle.settingKey));
if (parsed !== undefined) defaults[toggle.column] = parsed;
}
if (bySettingKey.has(KEYBIND_MODE_SETTING_KEY)) {
const parsed = parseKeybindModeSetting(bySettingKey.get(KEYBIND_MODE_SETTING_KEY));
if (parsed !== undefined) defaults.keybind_mode = parsed;
}
} catch (error) {
logger.error('Failed to read global feedback defaults, using built-ins', {
error: error.message,
});
}
return defaults;
}
/**
* Merge an explicit request body over the resolved globals. A value the caller
* actually sent always wins; `undefined` (the field was omitted) inherits.
*
* @param {Object} body - the create-request payload
* @param {Object} globals - output of resolveEventFeedbackDefaults()
*/
function applyFeedbackDefaults(body = {}, globals) {
const resolved = { ...globals };
for (const toggle of FEEDBACK_TOGGLES) {
if (body[toggle.column] !== undefined) {
resolved[toggle.column] = body[toggle.column];
}
}
if (KEYBIND_MODES.includes(body.keybind_mode)) {
resolved.keybind_mode = body.keybind_mode;
}
return resolved;
}
module.exports = {
FEEDBACK_TOGGLES,
FEEDBACK_DEFAULT_SETTING_KEYS,
KEYBIND_MODES,
KEYBIND_MODE_SETTING_KEY,
DEFAULT_KEYBIND_MODE,
resolveEventFeedbackDefaults,
applyFeedbackDefaults,
};
+124 -22
View File
@@ -2,6 +2,8 @@ const { db, logActivity } = require('../database/db');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { REACTION_EMOJIS } = require('../constants/reactions'); const { REACTION_EMOJIS } = require('../constants/reactions');
const { isValidColorLabel } = require('../constants/colorLabels');
const { resolveEventFeedbackDefaults, DEFAULT_KEYBIND_MODE, KEYBIND_MODES } = require('./feedbackDefaults');
// Every writable column on event_feedback_settings (#1030). The admin form // Every writable column on event_feedback_settings (#1030). The admin form
// posts its whole client-side state back, including UI-only keys that were // posts its whole client-side state back, including UI-only keys that were
@@ -17,6 +19,8 @@ const FEEDBACK_SETTINGS_COLUMNS = [
'allow_comments', 'allow_comments',
'allow_favorites', 'allow_favorites',
'allow_reactions', 'allow_reactions',
'allow_color_labels',
'keybind_mode',
'require_name_email', 'require_name_email',
'moderate_comments', 'moderate_comments',
'require_moderation', 'require_moderation',
@@ -26,6 +30,16 @@ const FEEDBACK_SETTINGS_COLUMNS = [
'max_likes_per_guest' 'max_likes_per_guest'
]; ];
/**
* Feedback types that store exactly ONE value per guest per photo, and the
* column each keeps it in. Both share the toggle-off / switch semantics in
* submitFeedback below.
*/
const SINGLE_VALUE_COLUMNS = {
reaction: 'reaction',
color_label: 'color_label',
};
function pickSettingsColumns(settings) { function pickSettingsColumns(settings) {
const picked = {}; const picked = {};
for (const column of FEEDBACK_SETTINGS_COLUMNS) { for (const column of FEEDBACK_SETTINGS_COLUMNS) {
@@ -47,15 +61,15 @@ class FeedbackService {
.first(); .first();
if (!settings) { if (!settings) {
// Return default settings if none exist // No row = feedback was never enabled for this event. The sub-toggles
// still matter: they are the state the admin's feedback panel opens
// with. Read them from the same global defaults the create routes use
// (#1044) instead of a fourth hard-coded copy that drifts from them.
const globals = await resolveEventFeedbackDefaults();
return { return {
event_id: eventId, event_id: eventId,
feedback_enabled: false, feedback_enabled: false,
allow_ratings: true, ...globals,
allow_likes: true,
allow_comments: false,
allow_favorites: true,
allow_reactions: true,
require_name_email: false, require_name_email: false,
moderate_comments: true, moderate_comments: true,
show_feedback_to_guests: true, show_feedback_to_guests: true,
@@ -69,6 +83,12 @@ class FeedbackService {
if (!settings.identity_mode) { if (!settings.identity_mode) {
settings.identity_mode = 'simple'; settings.identity_mode = 'simple';
} }
// Rows created before migration 180 have NULL keybind_mode. An
// unrecognised value gets the same treatment — the lightbox switches on
// this string and must never receive something it has no scheme for.
if (!KEYBIND_MODES.includes(settings.keybind_mode)) {
settings.keybind_mode = DEFAULT_KEYBIND_MODE;
}
// Per-guest caps (#655). NULL on existing rows = unlimited; the route // Per-guest caps (#655). NULL on existing rows = unlimited; the route
// layer treats null/0/missing identically. // layer treats null/0/missing identically.
settings.max_favorites_per_guest = settings.max_favorites_per_guest ?? null; settings.max_favorites_per_guest = settings.max_favorites_per_guest ?? null;
@@ -139,10 +159,10 @@ class FeedbackService {
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) { async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
try { try {
const { feedback_type, rating, comment_text, reaction, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData; const { feedback_type, rating, comment_text, reaction, color_label, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
// Validate feedback type // Validate feedback type
if (!['rating', 'like', 'comment', 'favorite', 'reaction'].includes(feedback_type)) { if (!['rating', 'like', 'comment', 'favorite', 'reaction', 'color_label'].includes(feedback_type)) {
throw new Error('Invalid feedback type'); throw new Error('Invalid feedback type');
} }
@@ -152,6 +172,11 @@ class FeedbackService {
throw new Error('Invalid reaction'); throw new Error('Invalid reaction');
} }
// Same contract for colour labels (#1044).
if (feedback_type === 'color_label' && !isValidColorLabel(color_label)) {
throw new Error('Invalid color label');
}
// Rating 0 clears the guest's rating (#884). Only the explicit zero // Rating 0 clears the guest's rating (#884). Only the explicit zero
// sentinel (0, or "0" from callers that skip the route validator's // sentinel (0, or "0" from callers that skip the route validator's
// toInt) triggers the destructive path — malformed input (undefined, // toInt) triggers the destructive path — malformed input (undefined,
@@ -209,35 +234,41 @@ class FeedbackService {
return { id: existing.id, updated: true }; return { id: existing.id, updated: true };
} }
// One reaction per guest per photo, changeable (#839): the same // Single-value types — one reaction (#839) and one colour label
// emoji again toggles it off; a different one switches the row. // (#1044) per guest per photo, both changeable: submitting the
// current value again toggles it off, a different one switches the
// row. Shared so the two paths can't drift.
//
// Toggle/switch act on the full guest-scoped QUERY, not existing.id: // Toggle/switch act on the full guest-scoped QUERY, not existing.id:
// like the sibling like path, the check-then-insert above can race // like the sibling like path, the check-then-insert above can race
// into duplicate rows — operating on the set makes the next // into duplicate rows — operating on the set makes the next
// interaction collapse them instead of leaving a phantom count. // interaction collapse them instead of leaving a phantom count.
const reactionScope = () => { const singleValueColumn = SINGLE_VALUE_COLUMNS[feedback_type];
if (singleValueColumn) {
const submittedValue = feedback_type === 'reaction' ? reaction : color_label;
const singleValueScope = () => {
const q = db('photo_feedback').where({ const q = db('photo_feedback').where({
photo_id: photoId, photo_id: photoId,
event_id: eventId, event_id: eventId,
feedback_type: 'reaction', feedback_type,
}); });
if (guest_id) q.where('guest_id', guest_id); if (guest_id) q.where('guest_id', guest_id);
else q.where('guest_identifier', guestIdentifier); else q.where('guest_identifier', guestIdentifier);
return q; return q;
}; };
if (feedback_type === 'reaction') {
if (existing.reaction === reaction) { if (existing[singleValueColumn] === submittedValue) {
await reactionScope().delete(); await singleValueScope().delete();
await this.updatePhotoFeedbackStats(photoId); await this.updatePhotoFeedbackStats(photoId);
return { removed: true }; return { removed: true };
} }
// Converge to exactly one row: drop any racy duplicates, then // Converge to exactly one row: drop any racy duplicates, then
// switch the surviving row's emoji. // switch the surviving row's value.
await reactionScope().whereNot('id', existing.id).delete(); await singleValueScope().whereNot('id', existing.id).delete();
await db('photo_feedback') await db('photo_feedback')
.where('id', existing.id) .where('id', existing.id)
.update({ .update({
reaction, [singleValueColumn]: submittedValue,
updated_at: new Date() updated_at: new Date()
}); });
await this.updatePhotoFeedbackStats(photoId); await this.updatePhotoFeedbackStats(photoId);
@@ -300,6 +331,7 @@ class FeedbackService {
rating: feedback_type === 'rating' ? rating : null, rating: feedback_type === 'rating' ? rating : null,
comment_text: feedback_type === 'comment' ? comment_text : null, comment_text: feedback_type === 'comment' ? comment_text : null,
reaction: feedback_type === 'reaction' ? reaction : null, reaction: feedback_type === 'reaction' ? reaction : null,
color_label: feedback_type === 'color_label' ? color_label : null,
guest_name, guest_name,
guest_email, guest_email,
guest_identifier: guestIdentifier, guest_identifier: guestIdentifier,
@@ -352,7 +384,7 @@ class FeedbackService {
const feedback = await query const feedback = await query
.orderBy('created_at', 'desc') .orderBy('created_at', 'desc')
.select('id', 'feedback_type', 'rating', 'comment_text', 'reaction', 'guest_name', 'created_at', 'is_approved', 'is_hidden'); .select('id', 'feedback_type', 'rating', 'comment_text', 'reaction', 'color_label', 'guest_name', 'created_at', 'is_approved', 'is_hidden');
return feedback; return feedback;
} catch (error) { } catch (error) {
@@ -368,7 +400,7 @@ class FeedbackService {
try { try {
const photos = await db('photos') const photos = await db('photos')
.where('event_id', eventId) .where('event_id', eventId)
.select('id', 'filename', 'feedback_count', 'like_count', 'average_rating', 'favorite_count', 'reaction_count') .select('id', 'filename', 'feedback_count', 'like_count', 'average_rating', 'favorite_count', 'reaction_count', 'color_label_count')
.orderBy('average_rating', 'desc') .orderBy('average_rating', 'desc')
.orderBy('like_count', 'desc'); .orderBy('like_count', 'desc');
@@ -380,7 +412,8 @@ class FeedbackService {
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_likes', ['like']), db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_likes', ['like']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_comments', ['comment']), db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_comments', ['comment']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_favorites', ['favorite']), db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_favorites', ['favorite']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_reactions', ['reaction']) db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_reactions', ['reaction']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_color_labels', ['color_label'])
) )
.first(); .first();
@@ -419,6 +452,67 @@ class FeedbackService {
} }
} }
/**
* Per-colour tallies for one photo (#1044) the colour-label sibling of
* getPhotoReactionCounts.
*/
async getPhotoColorLabelCounts(photoId) {
try {
const rows = await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'color_label' })
.where('is_hidden', false)
.groupBy('color_label')
.select('color_label')
.count('id as count');
const counts = {};
for (const row of rows) {
if (row.color_label) counts[row.color_label] = Number(row.count) || 0;
}
return counts;
} catch (error) {
logger.error('Error getting photo color label counts:', error);
throw error;
}
}
/**
* Per-colour tallies for every labelled photo in an event, keyed by photo
* id (#1044). One grouped query the admin grid needs this for a whole
* page of photos at once, and the per-photo helper above would be N+1.
*
* @param {number} eventId
* @param {number[]} [photoIds] - optional narrowing to the visible page
* @returns {Promise<Object>} { [photoId]: { green: 2, red: 1 } }
*/
async getEventColorLabelCounts(eventId, photoIds = null) {
try {
const query = db('photo_feedback')
.where({ event_id: eventId, feedback_type: 'color_label' })
.where('is_hidden', false)
.groupBy('photo_id', 'color_label')
.select('photo_id', 'color_label')
.count('id as count');
if (Array.isArray(photoIds)) {
if (photoIds.length === 0) return {};
query.whereIn('photo_id', photoIds);
}
const rows = await query;
const byPhoto = {};
for (const row of rows) {
if (!row.color_label) continue;
if (!byPhoto[row.photo_id]) byPhoto[row.photo_id] = {};
byPhoto[row.photo_id][row.color_label] = Number(row.count) || 0;
}
return byPhoto;
} catch (error) {
logger.error('Error getting event color label counts:', error);
return {};
}
}
/** /**
* Update photo feedback statistics * Update photo feedback statistics
*/ */
@@ -433,6 +527,7 @@ class FeedbackService {
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as like_count', ['like']), db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as like_count', ['like']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as favorite_count', ['favorite']), db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as favorite_count', ['favorite']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as reaction_count', ['reaction']), db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as reaction_count', ['reaction']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as color_label_count', ['color_label']),
db.raw('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']), db.raw('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']),
db.raw('COUNT(DISTINCT COALESCE(CAST(guest_id AS VARCHAR), guest_identifier)) as feedback_count') db.raw('COUNT(DISTINCT COALESCE(CAST(guest_id AS VARCHAR), guest_identifier)) as feedback_count')
) )
@@ -446,7 +541,8 @@ class FeedbackService {
like_count: stats.like_count || 0, like_count: stats.like_count || 0,
average_rating: stats.average_rating || 0, average_rating: stats.average_rating || 0,
favorite_count: stats.favorite_count || 0, favorite_count: stats.favorite_count || 0,
reaction_count: stats.reaction_count || 0 reaction_count: stats.reaction_count || 0,
color_label_count: stats.color_label_count || 0
}); });
} catch (error) { } catch (error) {
logger.error('Error updating photo feedback stats:', error); logger.error('Error updating photo feedback stats:', error);
@@ -587,6 +683,7 @@ class FeedbackService {
'photo_feedback.rating', 'photo_feedback.rating',
'photo_feedback.comment_text', 'photo_feedback.comment_text',
'photo_feedback.reaction', 'photo_feedback.reaction',
'photo_feedback.color_label',
'photo_feedback.guest_name', 'photo_feedback.guest_name',
'photo_feedback.guest_email', 'photo_feedback.guest_email',
'photo_feedback.created_at' 'photo_feedback.created_at'
@@ -625,6 +722,7 @@ class FeedbackService {
'photo_feedback.rating', 'photo_feedback.rating',
'photo_feedback.comment_text', 'photo_feedback.comment_text',
'photo_feedback.reaction', 'photo_feedback.reaction',
'photo_feedback.color_label',
'photo_feedback.guest_name', 'photo_feedback.guest_name',
'photo_feedback.guest_email', 'photo_feedback.guest_email',
'photo_feedback.guest_identifier', 'photo_feedback.guest_identifier',
@@ -651,6 +749,7 @@ class FeedbackService {
star_rating: '', star_rating: '',
comment: '', comment: '',
reaction: '', reaction: '',
color_label: '',
latest_at: row.created_at, latest_at: row.created_at,
}; };
byKey.set(key, entry); byKey.set(key, entry);
@@ -680,6 +779,9 @@ class FeedbackService {
case 'reaction': case 'reaction':
if (row.reaction) entry.reaction = row.reaction; if (row.reaction) entry.reaction = row.reaction;
break; break;
case 'color_label':
if (row.color_label) entry.color_label = row.color_label;
break;
default: default:
// Unknown feedback type — ignore so a future type doesn't break the export. // Unknown feedback type — ignore so a future type doesn't break the export.
break; break;
@@ -0,0 +1,185 @@
/**
* The photographer's own stars and colour labels (#1044 follow-up).
*
* Deliberately separate from photo_feedback see the migration-181 header for
* why. Nothing in here is ever read by a guest-facing route: admin marks show
* on admin surfaces and in exports, and the client's proofing view never sees
* what the photographer thought.
*/
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { isValidColorLabel } = require('../constants/colorLabels');
/**
* A mark the caller got wrong, as opposed to something that went wrong.
*
* Carries a `code` so the route can map it to a 400 without matching on the
* message text message-matching couples the status code to this file's copy,
* and a reworded string would silently turn a 400 into a 500.
*/
const INVALID_MARK = 'INVALID_MARK';
function invalidMark(message) {
return Object.assign(new Error(message), { code: INVALID_MARK });
}
/**
* Set, change or clear an admin's mark on one photo.
*
* `rating` and `colorLabel` are tri-state: `undefined` leaves that half of the
* mark alone, `null` clears it, a value sets it. That is what lets the
* lightbox's colour keys and star keys write independently without each
* wiping the other.
*
* @param {number} eventId
* @param {number} photoId
* @param {number} adminId
* @param {{rating?: number|null, colorLabel?: string|null}} mark
* @returns {Promise<{rating: number|null, color_label: string|null}|null>}
* the resulting mark, or null when it was cleared entirely
*/
async function setMark(eventId, photoId, adminId, { rating, colorLabel } = {}) {
if (rating !== undefined && rating !== null) {
const asInt = Number(rating);
if (!Number.isInteger(asInt) || asInt < 1 || asInt > 5) {
throw invalidMark('Rating must be between 1 and 5');
}
}
if (colorLabel !== undefined && colorLabel !== null && !isValidColorLabel(colorLabel)) {
throw invalidMark('Invalid color label');
}
/**
* Write ONLY the halves this call was asked about, onto the row as it
* stands right now.
*
* The obvious version recompute both halves from a pre-read and write the
* pair loses a concurrent write: press 4 then 9 on a photo that already
* has a mark and both calls read the old row, both write {rating,
* color_label}, and the second silently clobbers the first with its stale
* value. Not writing the untouched column at all means there is nothing to
* lose, and no race machinery is needed to achieve it.
*/
const applyToRow = async (row) => {
const now = new Date().toISOString();
const patch = { updated_at: now };
if (rating !== undefined) patch.rating = rating === null ? null : Number(rating);
if (colorLabel !== undefined) patch.color_label = colorLabel;
await db('photo_admin_marks').where('id', row.id).update(patch);
// A mark with neither half left is deleted, not kept as an empty row — an
// empty row would still count as "marked" to anything testing existence.
//
// The emptiness test is a predicate ON the delete rather than a re-read
// followed by a delete: a concurrent call that just filled a half back in
// must not have its value dropped by our stale view of the row.
const removed = await db('photo_admin_marks')
.where('id', row.id)
.whereNull('rating')
.whereNull('color_label')
.delete();
if (removed) return null;
const after = await db('photo_admin_marks').where('id', row.id).first();
if (!after) return null;
return { rating: after.rating ?? null, color_label: after.color_label ?? null };
};
const existing = await db('photo_admin_marks')
.where({ photo_id: photoId, admin_id: adminId })
.first();
if (existing) return applyToRow(existing);
// No row yet, so there is nothing for a clear-only call to clear.
const fresh = {
rating: rating === undefined || rating === null ? null : Number(rating),
color_label: colorLabel === undefined || colorLabel === null ? null : colorLabel,
};
if (fresh.rating === null && fresh.color_label === null) return null;
const now = new Date().toISOString();
try {
await db('photo_admin_marks').insert({
photo_id: photoId,
event_id: eventId,
admin_id: adminId,
...fresh,
created_at: now,
updated_at: now,
});
} catch (error) {
// The read above and this insert are not atomic, and a triage pass fires
// them back to back — press 4 then 9 on an UNMARKED photo and both
// requests can find no row and both try to insert. The unique index stops
// the duplicate, which would otherwise surface as a 500 and a lost
// keystroke; converge onto the row the winner created, through the same
// write-only-what-you-addressed path as any other update.
const raced = await db('photo_admin_marks')
.where({ photo_id: photoId, admin_id: adminId })
.first();
if (!raced) throw error;
return applyToRow(raced);
}
return fresh;
}
/**
* One admin's marks across an event, keyed by photo id. Optionally narrowed to
* the ids on the current page.
*
* @returns {Promise<Object>} { [photoId]: { rating, color_label } }
*/
async function getEventMarks(eventId, adminId, photoIds = null) {
try {
const query = db('photo_admin_marks')
.where({ event_id: eventId, admin_id: adminId })
.select('photo_id', 'rating', 'color_label');
if (Array.isArray(photoIds)) {
if (photoIds.length === 0) return {};
query.whereIn('photo_id', photoIds);
}
const rows = await query;
const byPhoto = {};
for (const row of rows) {
byPhoto[row.photo_id] = {
rating: row.rating ?? null,
color_label: row.color_label ?? null,
};
}
return byPhoto;
} catch (error) {
logger.error('Error reading admin photo marks:', error);
return {};
}
}
/**
* Per-colour photo counts for one admin's marks drives the counts on the
* "My marks" filter chips.
*/
async function getEventMarkColorCounts(eventId, adminId) {
try {
const rows = await db('photo_admin_marks')
.where({ event_id: eventId, admin_id: adminId })
.whereNotNull('color_label')
.groupBy('color_label')
.select('color_label')
.count('id as count');
const counts = {};
for (const row of rows) {
counts[row.color_label] = parseInt(row.count, 10) || 0;
}
return counts;
} catch (error) {
logger.error('Error counting admin photo marks:', error);
return {};
}
}
module.exports = { setMark, getEventMarks, getEventMarkColorCounts, INVALID_MARK };
+57 -5
View File
@@ -6,6 +6,9 @@
const archiver = require('archiver'); const archiver = require('archiver');
const { PassThrough } = require('stream'); const { PassThrough } = require('stream');
const { XmpGenerator } = require('./xmpGenerator'); const { XmpGenerator } = require('./xmpGenerator');
const { dominantColorLabel } = require('../constants/colorLabels');
const photoAdminMarksService = require('./photoAdminMarksService');
const feedbackService = require('./feedbackService');
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe'); const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
const { db } = require('../database/db'); const { db } = require('../database/db');
const path = require('path'); const path = require('path');
@@ -22,7 +25,7 @@ class PhotoExportService {
* @param {number[]} photoIds - Photo IDs to export (optional, exports all if not provided) * @param {number[]} photoIds - Photo IDs to export (optional, exports all if not provided)
* @returns {Promise<Object[]>} Photos with feedback * @returns {Promise<Object[]>} Photos with feedback
*/ */
async getPhotosWithFeedback(eventId, photoIds = null) { async getPhotosWithFeedback(eventId, photoIds = null, adminId = null) {
let query = db('photos') let query = db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', eventId) .where('photos.event_id', eventId)
@@ -36,6 +39,7 @@ class PhotoExportService {
'photos.like_count', 'photos.like_count',
'photos.favorite_count', 'photos.favorite_count',
'photos.comment_count', 'photos.comment_count',
'photos.color_label_count',
'photos.width', 'photos.width',
'photos.height', 'photos.height',
'photos.size_bytes', 'photos.size_bytes',
@@ -48,7 +52,33 @@ class PhotoExportService {
query = query.whereIn('photos.id', photoIds); query = query.whereIn('photos.id', photoIds);
} }
return await query; const photos = await query;
// Colour labels (#1044). One grouped query for the whole export, then
// each row carries both the per-colour tallies and the single colour the
// XMP sidecar should claim.
const colorCounts = await feedbackService.getEventColorLabelCounts(
eventId,
photos.map(p => p.id),
);
for (const photo of photos) {
photo.color_labels = colorCounts[photo.id] || {};
photo.dominant_color_label = dominantColorLabel(photo.color_labels);
}
// The exporting photographer's own marks (#1044 follow-up), so a triage
// pass can leave the app as XMP the same way a client selection can.
if (adminId) {
const marks = await photoAdminMarksService.getEventMarks(
eventId, adminId, photos.map(p => p.id),
);
for (const photo of photos) {
photo.my_rating = marks[photo.id]?.rating ?? null;
photo.my_color_label = marks[photo.id]?.color_label ?? null;
}
}
return photos;
} }
/** /**
@@ -60,7 +90,9 @@ class PhotoExportService {
* @returns {Promise<Object>} Export result with stream/content * @returns {Promise<Object>} Export result with stream/content
*/ */
async exportPhotos(eventId, photoIds, format, options = {}) { async exportPhotos(eventId, photoIds, format, options = {}) {
const photos = await this.getPhotosWithFeedback(eventId, photoIds); // admin_id is set by the route from the session, never taken from the
// request body — it decides whose marks the export carries.
const photos = await this.getPhotosWithFeedback(eventId, photoIds, options.admin_id || null);
if (photos.length === 0) { if (photos.length === 0) {
throw new Error('No photos to export'); throw new Error('No photos to export');
@@ -139,6 +171,9 @@ class PhotoExportService {
'likes', 'likes',
'favorites', 'favorites',
'comments', 'comments',
'color_label',
'my_rating',
'my_color_label',
'category', 'category',
'width', 'width',
'height', 'height',
@@ -154,6 +189,9 @@ class PhotoExportService {
photo.like_count || 0, photo.like_count || 0,
photo.favorite_count || 0, photo.favorite_count || 0,
photo.comment_count || 0, photo.comment_count || 0,
photo.dominant_color_label || '',
photo.my_rating ?? '',
photo.my_color_label || '',
photo.category_name || '', photo.category_name || '',
photo.width || '', photo.width || '',
photo.height || '', photo.height || '',
@@ -181,7 +219,17 @@ class PhotoExportService {
* Export as XMP sidecar files in a ZIP archive * Export as XMP sidecar files in a ZIP archive
*/ */
async exportAsXmpZip(photos, options = {}) { async exportAsXmpZip(photos, options = {}) {
const { filename_format = 'original' } = options; const { filename_format = 'original', mark_source = 'client' } = options;
// Whose verdict the sidecar carries (#1044 follow-up). Default 'client'
// keeps existing exports identical; 'mine' writes the photographer's own
// triage instead, which is the point of being able to mark at all.
const project = (photo) => (mark_source !== 'mine' ? photo : {
...photo,
average_rating: photo.my_rating || 0,
dominant_color_label: photo.my_color_label || null,
color_labels: {},
});
const archive = archiver('zip', { zlib: { level: 9 } }); const archive = archiver('zip', { zlib: { level: 9 } });
const passthrough = new PassThrough(); const passthrough = new PassThrough();
@@ -192,7 +240,7 @@ class PhotoExportService {
? (photo.original_filename || photo.filename) ? (photo.original_filename || photo.filename)
: photo.filename; : photo.filename;
const xmpFilename = this.xmpGenerator.getXmpFilename(baseFilename); const xmpFilename = this.xmpGenerator.getXmpFilename(baseFilename);
const xmpContent = this.xmpGenerator.generateXmp(photo, options); const xmpContent = this.xmpGenerator.generateXmp(project(photo), options);
archive.append(xmpContent, { name: xmpFilename }); archive.append(xmpContent, { name: xmpFilename });
} }
@@ -237,6 +285,10 @@ class PhotoExportService {
likes: photo.like_count || 0, likes: photo.like_count || 0,
favorites: photo.favorite_count || 0, favorites: photo.favorite_count || 0,
comments: photo.comment_count || 0, comments: photo.comment_count || 0,
color_label: photo.dominant_color_label || null,
color_labels: photo.color_labels || {},
my_rating: photo.my_rating ?? null,
my_color_label: photo.my_color_label || null,
dimensions: { dimensions: {
width: photo.width || null, width: photo.width || null,
height: photo.height || null height: photo.height || null
+44 -4
View File
@@ -3,6 +3,8 @@
* Generates Adobe XMP metadata files for photos with guest feedback * Generates Adobe XMP metadata files for photos with guest feedback
*/ */
const { COLOR_LABEL_TO_XMP, dominantColorLabel } = require('../constants/colorLabels');
class XmpGenerator { class XmpGenerator {
/** /**
* Generate XMP sidecar content for a photo * Generate XMP sidecar content for a photo
@@ -19,7 +21,7 @@ class XmpGenerator {
} = options; } = options;
const rating = include_rating ? this.mapRating(photo.average_rating) : 0; const rating = include_rating ? this.mapRating(photo.average_rating) : 0;
const label = include_label ? this.mapLabel(photo.average_rating) : null; const label = include_label ? this.mapLabel(photo) : null;
const descriptionXml = include_description ? this.generateDescription(photo) : ''; const descriptionXml = include_description ? this.generateDescription(photo) : '';
const keywordsXml = include_keywords ? this.generateKeywords(photo) : ''; const keywordsXml = include_keywords ? this.generateKeywords(photo) : '';
@@ -57,11 +59,39 @@ class XmpGenerator {
} }
/** /**
* Map PicPeak rating to XMP color label * Resolve the photo's XMP colour label.
*
* A real colour label the client set while proofing (#1044) always wins:
* that is the whole point of using Lightroom's colour set, and it is an
* explicit choice rather than something inferred. Only when a photo has no
* label does this fall back to the historical rating-derived mapping, so
* exports for events that never enabled colour labels are unchanged.
*
* @param {Object} photo - photo row, may carry dominant_color_label
* @returns {string|null} XMP label colour
*/
mapLabel(photo) {
// Tolerate being handed a bare rating: this used to take one, and
// callers outside the export service may still do so.
if (typeof photo === 'number' || photo === null || photo === undefined) {
return this.mapRatingToLabel(photo);
}
const colorLabel = photo.dominant_color_label
|| dominantColorLabel(photo.color_labels);
if (colorLabel && COLOR_LABEL_TO_XMP[colorLabel]) {
return COLOR_LABEL_TO_XMP[colorLabel];
}
return this.mapRatingToLabel(photo.average_rating);
}
/**
* The pre-#1044 mapping: infer a colour from the average star rating.
* @param {number} avgRating - Average rating * @param {number} avgRating - Average rating
* @returns {string|null} XMP label color * @returns {string|null} XMP label color
*/ */
mapLabel(avgRating) { mapRatingToLabel(avgRating) {
if (!avgRating || avgRating === 0) return null; if (!avgRating || avgRating === 0) return null;
if (avgRating >= 4.5) return 'Red'; // Top picks if (avgRating >= 4.5) return 'Red'; // Top picks
if (avgRating >= 3.5) return 'Yellow'; // Good if (avgRating >= 3.5) return 'Yellow'; // Good
@@ -80,7 +110,9 @@ class XmpGenerator {
const likes = photo.like_count || 0; const likes = photo.like_count || 0;
const favorites = photo.favorite_count || 0; const favorites = photo.favorite_count || 0;
const desc = `PicPeak Guest Feedback: ${rating} stars, ${likes} likes, ${favorites} favorites`; const colorLabel = photo.dominant_color_label || dominantColorLabel(photo.color_labels);
const colorPart = colorLabel ? `, ${colorLabel} label` : '';
const desc = `PicPeak Guest Feedback: ${rating} stars, ${likes} likes, ${favorites} favorites${colorPart}`;
return `<dc:description> return `<dc:description>
<rdf:Alt> <rdf:Alt>
@@ -113,6 +145,14 @@ class XmpGenerator {
keywords.push('favorited'); keywords.push('favorited');
} }
// A searchable keyword for the client's colour choice (#1044) — Lightroom
// can filter on xmp:Label directly, Bridge and Capture One users often
// find keywords easier.
const colorLabel = photo.dominant_color_label || dominantColorLabel(photo.color_labels);
if (colorLabel) {
keywords.push(`color-${colorLabel}`);
}
if (photo.category_name) { if (photo.category_name) {
keywords.push(this.sanitizeKeyword(photo.category_name)); keywords.push(this.sanitizeKeyword(photo.category_name));
} }
+14 -1
View File
@@ -2,6 +2,8 @@ const { body, param, validationResult } = require('express-validator');
const validator = require('validator'); const validator = require('validator');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('./emailNormalization'); const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('./emailNormalization');
const { REACTION_EMOJIS } = require('../constants/reactions'); const { REACTION_EMOJIS } = require('../constants/reactions');
const { COLOR_LABELS } = require('../constants/colorLabels');
const { KEYBIND_MODES } = require('../services/feedbackDefaults');
/** /**
* Validation rules for feedback submission * Validation rules for feedback submission
@@ -134,7 +136,7 @@ function getValidationRules(feedbackType) {
*/ */
const validateFeedbackSubmission = [ const validateFeedbackSubmission = [
body('feedback_type') body('feedback_type')
.isIn(['rating', 'like', 'comment', 'favorite', 'reaction']) .isIn(['rating', 'like', 'comment', 'favorite', 'reaction', 'color_label'])
.withMessage('Invalid feedback type'), .withMessage('Invalid feedback type'),
// Conditional validation based on feedback type. 0 clears the guest's // Conditional validation based on feedback type. 0 clears the guest's
@@ -152,6 +154,14 @@ const validateFeedbackSubmission = [
.custom((value) => REACTION_EMOJIS.includes(value)) .custom((value) => REACTION_EMOJIS.includes(value))
.withMessage('Invalid reaction'), .withMessage('Invalid reaction'),
// Colour labels (#1044): Lightroom's five colours only — the value ends up
// in an XMP field Lightroom parses, so free-form strings are rejected here
// rather than sanitised later.
body('color_label')
.if(body('feedback_type').equals('color_label'))
.custom((value) => COLOR_LABELS.includes(value))
.withMessage('Invalid color label'),
body('comment_text') body('comment_text')
.if(body('feedback_type').equals('comment')) .if(body('feedback_type').equals('comment'))
.trim() .trim()
@@ -194,6 +204,9 @@ const validateFeedbackSettings = [
body('allow_comments').optional().isBoolean(), body('allow_comments').optional().isBoolean(),
body('allow_favorites').optional().isBoolean(), body('allow_favorites').optional().isBoolean(),
body('allow_reactions').optional().isBoolean(), body('allow_reactions').optional().isBoolean(),
body('allow_color_labels').optional().isBoolean(),
body('keybind_mode').optional().isIn(KEYBIND_MODES)
.withMessage(`keybind_mode must be one of: ${KEYBIND_MODES.join(', ')}`),
body('require_name_email').optional().isBoolean(), body('require_name_email').optional().isBoolean(),
body('moderate_comments').optional().isBoolean(), body('moderate_comments').optional().isBoolean(),
body('show_feedback_to_guests').optional().isBoolean(), body('show_feedback_to_guests').optional().isBoolean(),
+71 -3
View File
@@ -3,6 +3,23 @@
* Builds Knex queries for filtering photos by feedback metrics * Builds Knex queries for filtering photos by feedback metrics
*/ */
const { COLOR_LABELS } = require('../constants/colorLabels');
/**
* Accept a colour filter as an array or a comma-separated string, drop
* anything that isn't one of the five known colours, and de-duplicate.
*/
function normalizeColorLabels(value) {
if (!value) return [];
const raw = Array.isArray(value) ? value : String(value).split(',');
const seen = new Set();
for (const entry of raw) {
const color = String(entry).trim().toLowerCase();
if (COLOR_LABELS.includes(color)) seen.add(color);
}
return [...seen];
}
class PhotoFilterBuilder { class PhotoFilterBuilder {
constructor(queryBuilder, eventId) { constructor(queryBuilder, eventId) {
this.query = queryBuilder; this.query = queryBuilder;
@@ -21,6 +38,9 @@ class PhotoFilterBuilder {
has_favorites, has_favorites,
min_favorites, min_favorites,
has_comments, has_comments,
color_labels,
my_color_labels,
admin_id,
category_id, category_id,
logic = 'AND' logic = 'AND'
} = filters; } = filters;
@@ -59,6 +79,36 @@ class PhotoFilterBuilder {
conditions.push(builder => builder.where('photos.comment_count', '>', 0)); conditions.push(builder => builder.where('photos.comment_count', '>', 0));
} }
// Colour labels (#1044): "only the greens". Can't read a denormalized
// count — "has any label" and "has a GREEN label" are different questions
// — so this is an EXISTS over photo_feedback, covered by the
// photo_feedback_color_label_idx index from migration 180.
const requestedColors = normalizeColorLabels(color_labels);
if (requestedColors.length > 0) {
conditions.push(builder => builder.whereExists(function () {
this.select('*')
.from('photo_feedback')
.whereRaw('photo_feedback.photo_id = photos.id')
.where('photo_feedback.feedback_type', 'color_label')
.where('photo_feedback.is_hidden', false)
.whereIn('photo_feedback.color_label', requestedColors);
}));
}
// The same question against the caller's own marks (#1044 follow-up).
// Requires admin_id: without one this would filter by every admin's marks
// at once, so it is skipped rather than silently widened.
const requestedMyColors = normalizeColorLabels(my_color_labels);
if (requestedMyColors.length > 0 && admin_id) {
conditions.push(builder => builder.whereExists(function () {
this.select('*')
.from('photo_admin_marks')
.whereRaw('photo_admin_marks.photo_id = photos.id')
.where('photo_admin_marks.admin_id', admin_id)
.whereIn('photo_admin_marks.color_label', requestedMyColors);
}));
}
if (category_id) { if (category_id) {
conditions.push(builder => builder.where('photos.category_id', category_id)); conditions.push(builder => builder.where('photos.category_id', category_id));
} }
@@ -143,18 +193,36 @@ class PhotoFilterBuilder {
db.raw('COUNT(CASE WHEN average_rating > 0 THEN 1 END) as with_ratings'), db.raw('COUNT(CASE WHEN average_rating > 0 THEN 1 END) as with_ratings'),
db.raw('COUNT(CASE WHEN like_count > 0 THEN 1 END) as with_likes'), db.raw('COUNT(CASE WHEN like_count > 0 THEN 1 END) as with_likes'),
db.raw('COUNT(CASE WHEN favorite_count > 0 THEN 1 END) as with_favorites'), db.raw('COUNT(CASE WHEN favorite_count > 0 THEN 1 END) as with_favorites'),
db.raw('COUNT(CASE WHEN comment_count > 0 THEN 1 END) as with_comments') db.raw('COUNT(CASE WHEN comment_count > 0 THEN 1 END) as with_comments'),
db.raw('COUNT(CASE WHEN color_label_count > 0 THEN 1 END) as with_color_labels')
) )
.first(); .first();
// Per-colour totals for the filter chips (#1044) — the swatch row shows
// "Green 42" so the photographer knows which colours are worth filtering.
const colorRows = await db('photo_feedback')
.where({ event_id: eventId, feedback_type: 'color_label' })
.where('is_hidden', false)
.groupBy('color_label')
.select('color_label')
.countDistinct('photo_id as count');
const colorLabelCounts = {};
for (const color of COLOR_LABELS) colorLabelCounts[color] = 0;
for (const row of colorRows) {
if (row.color_label) colorLabelCounts[row.color_label] = parseInt(row.count) || 0;
}
return { return {
total: parseInt(result.total) || 0, total: parseInt(result.total) || 0,
withRatings: parseInt(result.with_ratings) || 0, withRatings: parseInt(result.with_ratings) || 0,
withLikes: parseInt(result.with_likes) || 0, withLikes: parseInt(result.with_likes) || 0,
withFavorites: parseInt(result.with_favorites) || 0, withFavorites: parseInt(result.with_favorites) || 0,
withComments: parseInt(result.with_comments) || 0 withComments: parseInt(result.with_comments) || 0,
withColorLabels: parseInt(result.with_color_labels) || 0,
colorLabelCounts
}; };
} }
} }
module.exports = { PhotoFilterBuilder }; module.exports = { PhotoFilterBuilder, normalizeColorLabels };
@@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { X, Heart, Bookmark, Star, MessageCircle, Smile } from 'lucide-react'; import { X, Heart, Bookmark, Star, MessageCircle, Smile, Palette } from 'lucide-react';
import { Loading } from '../common'; import { Loading } from '../common';
import { guestsService, AdminGuest } from '../../services/guests.service'; import { guestsService, AdminGuest } from '../../services/guests.service';
import { AuthenticatedImage } from '../common/AuthenticatedImage'; import { AuthenticatedImage } from '../common/AuthenticatedImage';
@@ -14,13 +14,18 @@ interface AdminGuestDetailProps {
onClose: () => void; onClose: () => void;
} }
type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented' | 'reacted'; type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented' | 'reacted' | 'labeled';
export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, guest, onClose }) => { export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, guest, onClose }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate(); const { formatDateTime: fmtDateTime } = useLocalizedDate();
const [tab, setTab] = useState<Tab>('all'); const [tab, setTab] = useState<Tab>('all');
// Badges are plain strings in this grid, so a colour label shows as its
// name rather than a swatch — which also keeps it readable for anyone who
// can't tell the five colours apart.
const colorBadge = (color: string) => t(`feedback.colorLabels.${color}`, color);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin-guest-detail', eventId, guest.id], queryKey: ['admin-guest-detail', eventId, guest.id],
queryFn: () => guestsService.getGuestDetail(eventId, guest.id), queryFn: () => guestsService.getGuestDetail(eventId, guest.id),
@@ -32,6 +37,7 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
const rated = selections?.rated || []; const rated = selections?.rated || [];
const commented = selections?.commented || []; const commented = selections?.commented || [];
const reacted = selections?.reacted || []; const reacted = selections?.reacted || [];
const labeled = selections?.labeled || [];
// "all" view combines the three visual selection types. // "all" view combines the three visual selection types.
type GridItem = { photo: { id: number; filename: string; thumbnail_url: string }; badges: string[] }; type GridItem = { photo: { id: number; filename: string; thumbnail_url: string }; badges: string[] };
@@ -50,6 +56,7 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
favorited.forEach((p) => add(p, 'favorite')); favorited.forEach((p) => add(p, 'favorite'));
rated.forEach((r) => add(r.photo, 'rating')); rated.forEach((r) => add(r.photo, 'rating'));
reacted.forEach((r) => add(r.photo, r.reaction)); reacted.forEach((r) => add(r.photo, r.reaction));
labeled.forEach((r) => add(r.photo, colorBadge(r.color_label)));
const visibleItems: GridItem[] = const visibleItems: GridItem[] =
tab === 'all' tab === 'all'
@@ -62,6 +69,8 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}`] })) ? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}`] }))
: tab === 'reacted' : tab === 'reacted'
? reacted.map((r) => ({ photo: r.photo, badges: [r.reaction] })) ? reacted.map((r) => ({ photo: r.photo, badges: [r.reaction] }))
: tab === 'labeled'
? labeled.map((r) => ({ photo: r.photo, badges: [colorBadge(r.color_label)] }))
: []; : [];
return ( return (
@@ -137,11 +146,20 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
{t('admin.guests.columns.reactions', 'Reactions')} {t('admin.guests.columns.reactions', 'Reactions')}
</div> </div>
</div> </div>
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{labeled.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<Palette className="w-3 h-3" />
{t('admin.guests.columns.colorLabels', 'Color labels')}
</div>
</div>
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-700 mb-4"> <div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-700 mb-4">
{(['all', 'liked', 'favorited', 'rated', 'commented', 'reacted'] as const).map((k) => ( {(['all', 'liked', 'favorited', 'rated', 'commented', 'reacted', 'labeled'] as const).map((k) => (
<button <button
key={k} key={k}
type="button" type="button"
@@ -233,6 +233,9 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase"> <th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.reactions', 'Reactions')} {t('admin.guests.columns.reactions', 'Reactions')}
</th> </th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.colorLabels', 'Color labels')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase"> <th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.lastSeen', 'Last seen')} {t('admin.guests.columns.lastSeen', 'Last seen')}
</th> </th>
@@ -276,6 +279,9 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100"> <td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.reactions} {guest.stats.reactions}
</td> </td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.color_labels ?? 0}
</td>
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400"> <td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
{fmtDate(guest.last_seen_at)} {fmtDate(guest.last_seen_at)}
</td> </td>
@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Check, Download, Trash2, Eye, EyeOff, Heart, Package, MessageSquare, Star, Video, FolderOpen, Cog, AlertTriangle, RefreshCw, LayoutGrid, List } from 'lucide-react'; import { Check, Download, Trash2, Eye, EyeOff, Heart, Package, MessageSquare, Star, Video, FolderOpen, Cog, AlertTriangle, RefreshCw, LayoutGrid, List } from 'lucide-react';
import { COLOR_LABEL_SWATCHES, type ColorLabel } from '../../services/feedback.service';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -465,6 +466,56 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</div> </div>
)} )}
{/* Color label (#1044). Bottom-left, opposite the rating/comment
indicators, so a labelled photo reads at a glance in the
admin grid the same way it does in the client's gallery. */}
{photo.dominant_color_label && COLOR_LABEL_SWATCHES[photo.dominant_color_label as ColorLabel] && (
<div className="absolute bottom-2 left-2 z-10">
<span
className="flex items-center justify-center w-5 h-5 rounded-full border-2 border-white/90 shadow"
style={{ backgroundColor: COLOR_LABEL_SWATCHES[photo.dominant_color_label as ColorLabel].fill }}
role="img"
aria-label={t('feedback.markedAs', 'Marked as {{color}}', {
color: t(`feedback.colorLabels.${photo.dominant_color_label}`, photo.dominant_color_label),
})}
title={t('feedback.markedAs', 'Marked as {{color}}', {
color: t(`feedback.colorLabels.${photo.dominant_color_label}`, photo.dominant_color_label),
})}
/>
</div>
)}
{/* The admin's OWN mark (#1044 follow-up), next to the client's
dot but visually distinct a white ring and a star count
so a triage pass is never confused with what the client
chose. */}
{(photo.my_color_label || photo.my_rating) && (
<div className="absolute bottom-2 left-9 z-10 flex items-center gap-1">
{photo.my_color_label && COLOR_LABEL_SWATCHES[photo.my_color_label as ColorLabel] && (
<span
className="w-5 h-5 rounded-full border-2 border-dashed border-white shadow"
style={{ backgroundColor: COLOR_LABEL_SWATCHES[photo.my_color_label as ColorLabel].fill }}
role="img"
aria-label={t('admin.photos.yourMarkColor', 'Your mark: {{color}}', {
color: t(`feedback.colorLabels.${photo.my_color_label}`, photo.my_color_label),
})}
title={t('admin.photos.yourMarkColor', 'Your mark: {{color}}', {
color: t(`feedback.colorLabels.${photo.my_color_label}`, photo.my_color_label),
})}
/>
)}
{!!photo.my_rating && (
<span
className="bg-white/90 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-xs font-medium text-neutral-700 flex items-center gap-0.5"
title={t('admin.photos.yourMarkRating', 'Your rating: {{count}}', { count: photo.my_rating })}
>
<Star className="w-3 h-3 text-yellow-500" fill="currentColor" />
{photo.my_rating}
</span>
)}
</div>
)}
{/* Feedback Indicators (moved to bottom-right to avoid covering category) */} {/* Feedback Indicators (moved to bottom-right to avoid covering category) */}
{(commentCount > 0 || averageRating > 0 || likeCount > 0) && ( {(commentCount > 0 || averageRating > 0 || likeCount > 0) && (
<div className="absolute bottom-2 right-2 flex items-center gap-1 z-10"> <div className="absolute bottom-2 right-2 flex items-center gap-1 z-10">
@@ -10,6 +10,9 @@ import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo'; import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { COLOR_LABELS, COLOR_LABEL_SWATCHES, type ColorLabel, type KeybindMode } from '../../services/feedback.service';
import { resolveFeedbackKey, colorShortcutHints } from '../../utils/feedbackKeybinds';
import { useTranslation } from 'react-i18next';
import { useMutationWithToast, useModal } from '../../hooks'; import { useMutationWithToast, useModal } from '../../hooks';
type AdminFeedbackResponse = { type AdminFeedbackResponse = {
@@ -36,6 +39,11 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
}) => { }) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex); const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const { t } = useTranslation();
// The photographer's own triage mark (#1044 follow-up). Held locally and
// seeded from the row so the star/colour UI responds instantly; the grid
// picks it up when its query is invalidated.
const [myMarks, setMyMarks] = useState<Record<number, { rating: number | null; color_label: ColorLabel | null }>>({});
const categoryMenuModal = useModal(); const categoryMenuModal = useModal();
const commentsModal = useModal(); const commentsModal = useModal();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -143,6 +151,52 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
errorMessage: () => 'Failed to delete feedback' errorMessage: () => 'Failed to delete feedback'
}); });
// The mark shown for a photo: the local edit if there is one, otherwise
// whatever the list query loaded.
const markFor = (photo: AdminPhoto) => myMarks[photo.id] ?? {
rating: photo.my_rating ?? null,
color_label: (photo.my_color_label as ColorLabel) ?? null,
};
const currentMark = markFor(currentPhoto);
const saveMark = async (patch: { rating?: number | null; color_label?: ColorLabel | null }) => {
const photoId = currentPhoto.id;
const previous = markFor(currentPhoto);
const optimistic = {
rating: patch.rating === undefined ? previous.rating : patch.rating,
color_label: patch.color_label === undefined ? previous.color_label : patch.color_label,
};
setMyMarks((prev) => ({ ...prev, [photoId]: optimistic }));
try {
await photosService.setPhotoMark(eventId, photoId, patch);
// The grid reads my_rating / my_color_label off the photo rows.
queryClient.invalidateQueries({ queryKey: ['admin-event-photos', String(eventId)] });
} catch {
setMyMarks((prev) => ({ ...prev, [photoId]: previous }));
toast.error(t('admin.photos.markError', 'Failed to save your mark'));
}
};
// Pressing the same value again clears it, matching the gallery lightbox.
//
// 0 is the clear sentinel — Lightroom's own binding, and what
// resolveFeedbackKey returns for the '0' key. It must become `null` here:
// the mark service stores 1-5 only and rejects a literal 0, so passing it
// straight through turned "clear my rating" into an error toast.
const toggleMarkRating = (value: number) =>
saveMark({ rating: value === 0 || currentMark.rating === value ? null : value });
const toggleMarkColor = (color: ColorLabel) =>
saveMark({ color_label: currentMark.color_label === color ? null : color });
// The admin viewer always uses the Lightroom bindings — 1-5 stars, 6-9
// colours — regardless of the scheme chosen for the gallery. That scheme is
// a choice made FOR the client; this surface belongs to the photographer,
// who came from Lightroom and needs both halves on the keyboard. Resolved
// through the shared helper so the two viewers can't drift.
const keybindMode: KeybindMode = 'lightroom';
const markRef = React.useRef({ currentMark, toggleMarkRating, toggleMarkColor, saveMark });
markRef.current = { currentMark, toggleMarkRating, toggleMarkColor, saveMark };
React.useEffect(() => { React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) { switch (e.key) {
@@ -155,6 +209,23 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
case 'ArrowRight': case 'ArrowRight':
goToNext(); goToNext();
break; break;
default: {
// Proofing shortcuts for the photographer's own marks (#1044
// follow-up). Read through a ref: this effect is keyed on
// currentIndex, so the closure would otherwise mark the photo that
// was open when it was registered.
const action = resolveFeedbackKey(e, {
mode: keybindMode,
allowColorLabels: true,
allowRatings: true,
});
if (!action) break;
e.preventDefault();
if (action.type === 'color') void markRef.current.toggleMarkColor(action.color);
else if (action.type === 'rating') void markRef.current.toggleMarkRating(action.value);
else void markRef.current.saveMark({ color_label: null });
break;
}
} }
}; };
@@ -331,6 +402,74 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
)} )}
</div> </div>
{/* The photographer's own marks (#1044 follow-up). Above the guest
feedback block on purpose: this is the surface being used during
a triage pass, and it is explicitly labelled as private so
nobody mistakes it for what the client chose. */}
<div className="mt-6 pt-6 border-t border-neutral-700">
<h4 className="text-white font-medium mb-1 flex items-center gap-2">
<Star className="w-4 h-4" />
{t('admin.photos.myMarks', 'Your marks')}
</h4>
<p className="text-xs text-neutral-400 mb-3">
{t('admin.photos.myMarksHelp', 'Only you see these. They never appear in the client gallery, and they export to Lightroom as XMP.')}
</p>
<div className="flex items-center gap-1 mb-3" aria-label={t('admin.photos.myRating', 'Your rating')}>
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
onClick={() => toggleMarkRating(value)}
className="p-0.5"
aria-pressed={currentMark.rating === value}
aria-label={currentMark.rating === value
? t('admin.photos.clearRating', 'Clear your rating')
: t('admin.photos.rateStars', 'Rate {{count}} stars', { count: value })}
title={`${value}`}
>
<Star
className={`w-5 h-5 ${(currentMark.rating || 0) >= value ? 'text-yellow-400' : 'text-neutral-600'}`}
fill={(currentMark.rating || 0) >= value ? 'currentColor' : 'none'}
/>
</button>
))}
<span className="ml-2 text-xs text-neutral-500">15</span>
</div>
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label={t('feedback.colorLabelsTitle', 'Color labels')}>
{COLOR_LABELS.map((color) => {
const swatch = COLOR_LABEL_SWATCHES[color];
const isMine = currentMark.color_label === color;
const shortcut = colorShortcutHints(keybindMode)[color];
const name = t(`feedback.colorLabels.${color}`, color);
return (
<button
key={color}
type="button"
onClick={() => toggleMarkColor(color)}
aria-pressed={isMine}
// Colour alone can't carry which swatch this is.
aria-label={isMine
? t('feedback.removeColorLabel', 'Remove {{color}} label', { color: name })
: t('feedback.setColorLabel', 'Mark as {{color}}', { color: name })}
title={shortcut ? `${name} (${shortcut})` : name}
className={`flex items-center gap-1 pl-1.5 pr-2 py-1 rounded-full text-xs transition-all ${
isMine ? 'bg-white/15 ring-1 ring-white/60' : 'bg-white/5 hover:bg-white/10'
}`}
>
<span
className="w-3.5 h-3.5 rounded-full border shrink-0"
style={{ backgroundColor: swatch.fill, borderColor: swatch.ring }}
aria-hidden="true"
/>
{shortcut && <span className="text-neutral-400">{shortcut}</span>}
</button>
);
})}
</div>
</div>
{/* Feedback Section */} {/* Feedback Section */}
{feedbackData && ( {feedbackData && (
<div className="mt-6 pt-6 border-t border-neutral-700"> <div className="mt-6 pt-6 border-t border-neutral-700">
@@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye, User, Users, Smile } from 'lucide-react'; import { MessageSquare, Star, Heart, Bookmark, Shield, Eye, User, Users, Smile, Palette, Keyboard } from 'lucide-react';
import { Card } from '../common'; import { Card } from '../common';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { COLOR_LABELS, COLOR_LABEL_SWATCHES, KEYBIND_SCHEMES, type KeybindMode } from '../../services/feedback.service';
interface FeedbackSettingsProps { interface FeedbackSettingsProps {
settings: FeedbackSettings; settings: FeedbackSettings;
@@ -16,6 +17,8 @@ interface FeedbackSettings {
allow_comments: boolean; allow_comments: boolean;
allow_favorites: boolean; allow_favorites: boolean;
allow_reactions: boolean; allow_reactions: boolean;
allow_color_labels: boolean;
keybind_mode?: KeybindMode;
require_name_email: boolean; require_name_email: boolean;
moderate_comments: boolean; moderate_comments: boolean;
show_feedback_to_guests: boolean; show_feedback_to_guests: boolean;
@@ -238,8 +241,104 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
</div> </div>
</div> </div>
</label> </label>
{/* Color labels (#1044) */}
<label className="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
<input
type="checkbox"
checked={settings.allow_color_labels}
onChange={() => handleToggle('allow_color_labels')}
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<Palette className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
{t('feedback.settings.colorLabels', 'Color Labels')}
<span className="flex items-center gap-1" aria-hidden="true">
{COLOR_LABELS.map((color) => (
<span
key={color}
className="w-3 h-3 rounded-full border"
style={{
backgroundColor: COLOR_LABEL_SWATCHES[color].fill,
borderColor: COLOR_LABEL_SWATCHES[color].ring,
}}
/>
))}
</span>
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('feedback.settings.colorLabelsDesc', "One color per guest per photo, using Lightroom's color set so selections carry over via XMP")}
</div> </div>
</div> </div>
</label>
</div>
</div>
{/* Keyboard scheme for the lightbox (#1044). Only meaningful once
color labels are on stars alone already use 1-5. */}
{settings.allow_color_labels && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300 flex items-center gap-2">
<Keyboard className="w-4 h-4" />
{t('feedback.settings.keybindMode', 'Keyboard shortcuts')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(['colors', 'lightroom'] as KeybindMode[]).map((mode) => (
<label
key={mode}
className={`flex gap-3 p-3 rounded-lg cursor-pointer border ${
(settings.keybind_mode || 'colors') === mode
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 hover:bg-neutral-100 dark:hover:bg-neutral-700'
}`}
>
<input
type="radio"
name="keybind_mode"
checked={(settings.keybind_mode || 'colors') === mode}
onChange={() => onChange({ ...settings, keybind_mode: mode })}
className="mt-1 w-4 h-4 text-accent border-neutral-300 focus:ring-primary-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{mode === 'colors'
? t('feedback.settings.keybindColors', 'Colors only (simplest)')
: t('feedback.settings.keybindLightroom', 'Lightroom defaults')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{mode === 'colors'
? t('feedback.settings.keybindColorsDesc', '1 = green (1st choice), 2 = yellow (2nd choice), 3 = red (rejected)')
: t('feedback.settings.keybindLightroomDesc', '1-5 set the star rating, 6-9 set red / yellow / green / blue')}
</div>
{/* The actual keymap, read from the shared scheme so
this preview can never claim a binding the
lightbox doesn't have. */}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{Object.entries(KEYBIND_SCHEMES[mode].colors).map(([key, color]) => (
<span
key={key}
className="flex items-center gap-1 text-[11px] text-neutral-600 dark:text-neutral-300"
>
<kbd className="px-1.5 py-0.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900">
{key}
</kbd>
<span
className="w-3 h-3 rounded-full border"
style={{
backgroundColor: COLOR_LABEL_SWATCHES[color].fill,
borderColor: COLOR_LABEL_SWATCHES[color].ring,
}}
/>
</span>
))}
</div>
</div>
</label>
))}
</div>
</div>
)}
{/* Per-guest caps (#655). Two numeric inputs; 0 / empty = unlimited. {/* Per-guest caps (#655). Two numeric inputs; 0 / empty = unlimited.
Only renders when the matching toggle is on the cap is Only renders when the matching toggle is on the cap is
@@ -85,6 +85,10 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
}, },
}); });
// Whose verdict the XMP sidecars carry (#1044 follow-up). Defaults to the
// client's selections, so existing exports are unchanged.
const [markSource, setMarkSource] = useState<'client' | 'mine'>('client');
const handleExport = (format: 'txt' | 'csv' | 'xmp' | 'json') => { const handleExport = (format: 'txt' | 'csv' | 'xmp' | 'json') => {
const options: ExportOptions = { const options: ExportOptions = {
format, format,
@@ -98,7 +102,8 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
include_rating: true, include_rating: true,
include_label: true, include_label: true,
include_description: true, include_description: true,
include_keywords: true include_keywords: true,
mark_source: markSource
} }
}; };
@@ -115,6 +120,9 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
has_favorites: filters.hasFavorites, has_favorites: filters.hasFavorites,
min_favorites: filters.minFavorites, min_favorites: filters.minFavorites,
has_comments: filters.hasComments, has_comments: filters.hasComments,
// #1044 — lets "export only the greens" round-trip to Lightroom.
color_labels: filters.colorLabels?.length ? filters.colorLabels : undefined,
my_color_labels: filters.myColorLabels?.length ? filters.myColorLabels : undefined,
category_id: filters.categoryId, category_id: filters.categoryId,
logic: filters.logic, logic: filters.logic,
sort: filters.sort, sort: filters.sort,
@@ -134,7 +142,9 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
filters.minRating !== null || filters.minRating !== null ||
filters.hasLikes || filters.hasLikes ||
filters.hasFavorites || filters.hasFavorites ||
filters.hasComments filters.hasComments ||
(filters.colorLabels?.length || 0) > 0 ||
(filters.myColorLabels?.length || 0) > 0
); );
const isDisabled = disabled || (!hasSelection && !hasFilters); const isDisabled = disabled || (!hasSelection && !hasFilters);
@@ -187,6 +197,22 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
} }
</p> </p>
{/* Whose stars/colours the XMP sidecars carry (#1044
follow-up). Only affects XMP the CSV and JSON exports
carry both columns regardless. */}
<label className="flex items-center justify-between gap-2 px-3 py-2 text-xs text-neutral-600 dark:text-neutral-400">
<span>{t('export.markSource', 'XMP stars & colour from')}</span>
<select
value={markSource}
onChange={(e) => setMarkSource(e.target.value === 'mine' ? 'mine' : 'client')}
className="px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
onClick={(e) => e.stopPropagation()}
>
<option value="client">{t('export.markSourceClient', 'Client selections')}</option>
<option value="mine">{t('export.markSourceMine', 'Your marks')}</option>
</select>
</label>
{EXPORT_FORMATS.map((format) => { {EXPORT_FORMATS.map((format) => {
const Icon = format.icon; const Icon = format.icon;
return ( return (
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { Star, Heart, Bookmark, MessageCircle, Filter, X } from 'lucide-react'; import { Star, Heart, Bookmark, MessageCircle, Filter, X } from 'lucide-react';
import { COLOR_LABELS, COLOR_LABEL_SWATCHES, type ColorLabel } from '../../services/feedback.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '../common'; import { Button } from '../common';
import { FeedbackFilters, FilterSummary } from '../../services/photos.service'; import { FeedbackFilters, FilterSummary } from '../../services/photos.service';
@@ -37,6 +38,31 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
onChange({ ...filters, [field]: !filters[field] }); onChange({ ...filters, [field]: !filters[field] });
}; };
// Colour labels are multi-select (#1044): each swatch toggles its colour,
// an empty list means no colour filtering.
const toggleColorLabel = (color: ColorLabel) => {
const active = filters.colorLabels || [];
onChange({
...filters,
colorLabels: active.includes(color)
? active.filter(c => c !== color)
: [...active, color],
});
};
// The same row against the admin's own marks (#1044 follow-up), kept as a
// separate filter rather than merged with the client's — "the client's
// greens" and "my greens" are different questions during a cull.
const toggleMyColorLabel = (color: ColorLabel) => {
const active = filters.myColorLabels || [];
onChange({
...filters,
myColorLabels: active.includes(color)
? active.filter(c => c !== color)
: [...active, color],
});
};
const handleLogicChange = (logic: 'AND' | 'OR') => { const handleLogicChange = (logic: 'AND' | 'OR') => {
onChange({ ...filters, logic }); onChange({ ...filters, logic });
}; };
@@ -47,6 +73,8 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
hasLikes: false, hasLikes: false,
hasFavorites: false, hasFavorites: false,
hasComments: false, hasComments: false,
colorLabels: [],
myColorLabels: [],
logic: 'AND' logic: 'AND'
}); });
}; };
@@ -54,7 +82,9 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
const hasActiveFilters = filters.minRating !== null || const hasActiveFilters = filters.minRating !== null ||
filters.hasLikes || filters.hasLikes ||
filters.hasFavorites || filters.hasFavorites ||
filters.hasComments; filters.hasComments ||
(filters.colorLabels?.length || 0) > 0 ||
(filters.myColorLabels?.length || 0) > 0;
return ( return (
<div className="bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 p-4 mb-4"> <div className="bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 p-4 mb-4">
@@ -150,6 +180,90 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
</label> </label>
</div> </div>
{/* Color labels (#1044). Rendered only when someone has actually
labelled something an always-visible swatch row would be dead
UI in the many galleries that never turn the feature on. */}
{(summary?.withColorLabels || 0) > 0 && (
<div>
<span className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('filter.colorLabels', 'Color labels')}
</span>
<div className="flex flex-wrap items-center gap-2">
{COLOR_LABELS.map((color) => {
const count = summary?.colorLabelCounts?.[color] || 0;
const isActive = (filters.colorLabels || []).includes(color);
const swatch = COLOR_LABEL_SWATCHES[color];
const name = t(`feedback.colorLabels.${color}`, color);
return (
<button
key={color}
type="button"
onClick={() => toggleColorLabel(color)}
disabled={isLoading}
aria-pressed={isActive}
aria-label={t('filter.showOnlyColor', 'Show only {{color}}', { color: name })}
className={`flex items-center gap-2 px-2.5 py-1 rounded-full border text-sm transition-colors ${
isActive
? 'border-accent-dark bg-accent-dark/10 text-neutral-900 dark:text-neutral-100'
: 'border-neutral-200 dark:border-neutral-700 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
}`}
>
<span
className="w-3.5 h-3.5 rounded-full border shrink-0"
style={{ backgroundColor: swatch.fill, borderColor: swatch.ring }}
aria-hidden="true"
/>
<span>{name}</span>
<span className="text-neutral-500 dark:text-neutral-400">({count})</span>
</button>
);
})}
</div>
</div>
)}
{/* The admin's own marks (#1044 follow-up). Same shape as the row
above, labelled so the two are never confused. */}
{Object.keys(summary?.myColorLabelCounts || {}).length > 0 && (
<div>
<span className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('filter.myColorLabels', 'Your marks')}
</span>
<div className="flex flex-wrap items-center gap-2">
{COLOR_LABELS.map((color) => {
const count = summary?.myColorLabelCounts?.[color] || 0;
if (count === 0 && !(filters.myColorLabels || []).includes(color)) return null;
const isActive = (filters.myColorLabels || []).includes(color);
const swatch = COLOR_LABEL_SWATCHES[color];
const name = t(`feedback.colorLabels.${color}`, color);
return (
<button
key={color}
type="button"
onClick={() => toggleMyColorLabel(color)}
disabled={isLoading}
aria-pressed={isActive}
aria-label={t('filter.showOnlyMyColor', 'Show only my {{color}} marks', { color: name })}
className={`flex items-center gap-2 px-2.5 py-1 rounded-full border text-sm transition-colors ${
isActive
? 'border-accent-dark bg-accent-dark/10 text-neutral-900 dark:text-neutral-100'
: 'border-neutral-200 dark:border-neutral-700 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
}`}
>
<span
className="w-3.5 h-3.5 rounded-full border-2 border-dashed shrink-0"
style={{ backgroundColor: swatch.fill, borderColor: swatch.ring }}
aria-hidden="true"
/>
<span>{name}</span>
<span className="text-neutral-500 dark:text-neutral-400">({count})</span>
</button>
);
})}
</div>
</div>
)}
{/* Logic Toggle */} {/* Logic Toggle */}
{(filters.hasLikes || filters.hasFavorites || filters.hasComments) && ( {(filters.hasLikes || filters.hasFavorites || filters.hasComments) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -0,0 +1,46 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { COLOR_LABEL_SWATCHES, type ColorLabel } from '../../services/feedback.service';
interface ColorLabelBadgeProps {
colorLabel?: string | null;
/** Extra classes for positioning inside the tile. */
className?: string;
}
/**
* The colour a guest gave a photo, shown on the thumbnail (#1044).
*
* The whole point of the feature is that a client can see their selection
* progress across the grid without reopening anything, so this is deliberately
* loud: an inset ring around the tile plus a corner dot. Both are
* pointer-events-none so they never swallow a click meant for the tile.
*/
export const ColorLabelBadge: React.FC<ColorLabelBadgeProps> = ({ colorLabel, className = '' }) => {
const { t } = useTranslation();
if (!colorLabel || !(colorLabel in COLOR_LABEL_SWATCHES)) return null;
const swatch = COLOR_LABEL_SWATCHES[colorLabel as ColorLabel];
const name = t(`feedback.colorLabels.${colorLabel}`, colorLabel);
return (
<>
<span
className={`absolute inset-0 pointer-events-none rounded-[inherit] ${className}`}
// Inset rather than an outline: the tile is often flush against its
// neighbours in masonry/justified layouts, where an outer ring would
// be clipped.
style={{ boxShadow: `inset 0 0 0 3px ${swatch.fill}` }}
aria-hidden="true"
/>
<span
className="absolute top-2 left-2 pointer-events-none flex items-center justify-center w-5 h-5 rounded-full border-2 border-white/90 shadow"
style={{ backgroundColor: swatch.fill }}
// Colour alone can't carry the meaning — the accessible name does.
title={t('feedback.markedAs', 'Marked as {{color}}', { color: name })}
role="img"
aria-label={t('feedback.markedAs', 'Marked as {{color}}', { color: name })}
/>
</>
);
};
@@ -0,0 +1,79 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { COLOR_LABELS, COLOR_LABEL_SWATCHES, type ColorLabel } from '../../services/feedback.service';
interface ColorLabelFilterChipsProps {
activeColors: ColorLabel[];
onToggle: (color: ColorLabel) => void;
/** Per-colour counts for the viewer's own labels. */
counts?: Partial<Record<ColorLabel, number>>;
/** Hide colours nobody has used yet. On by default five permanently
* empty swatches are noise in a gallery that isn't using every colour. */
hideEmpty?: boolean;
className?: string;
showLabel?: boolean;
}
/**
* "Show only the greens" (#1044). Multi-select: each chip toggles its colour,
* an empty set means no colour filtering.
*/
export const ColorLabelFilterChips: React.FC<ColorLabelFilterChipsProps> = ({
activeColors,
onToggle,
counts = {},
hideEmpty = true,
className = '',
showLabel = true,
}) => {
const { t } = useTranslation();
const visible = COLOR_LABELS.filter(color =>
!hideEmpty || (counts[color] || 0) > 0 || activeColors.includes(color)
);
if (visible.length === 0) return null;
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm text-muted-theme whitespace-nowrap">
{t('gallery.colorFilter', 'Color')}
</span>
)}
<div className="flex items-center gap-1 flex-wrap">
{visible.map((color) => {
const isActive = activeColors.includes(color);
const swatch = COLOR_LABEL_SWATCHES[color];
const count = counts[color] || 0;
const name = t(`feedback.colorLabels.${color}`, color);
return (
<button
key={color}
type="button"
onClick={() => onToggle(color)}
aria-pressed={isActive}
// Colour is the only thing distinguishing these chips, so the
// name has to carry it for screen readers and colour-blind
// viewers; the count is part of the visible label.
aria-label={t('gallery.filterByColor', 'Show only {{color}}', { color: name })}
title={`${name}${count > 0 ? ` (${count})` : ''}`}
className={`flex items-center gap-1.5 pl-1.5 pr-2 h-8 rounded-full border text-xs transition-all ${
isActive
? 'border-current ring-2 ring-offset-1 ring-current text-theme'
: 'border-black/15 text-muted-theme hover:border-current'
}`}
style={isActive ? { color: swatch.ring } : undefined}
>
<span
className="w-4 h-4 rounded-full border shrink-0"
style={{ backgroundColor: swatch.fill, borderColor: swatch.ring }}
aria-hidden="true"
/>
{count > 0 && <span className="font-medium">{count}</span>}
</button>
);
})}
</div>
</div>
);
};
@@ -4,6 +4,8 @@ import { Button } from '../common';
import { PhotoCategory } from '../../types'; import { PhotoCategory } from '../../types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { GalleryFilter, type FilterType, type FeedbackFilterType } from './GalleryFilter'; import { GalleryFilter, type FilterType, type FeedbackFilterType } from './GalleryFilter';
import { ColorLabelFilterChips } from './ColorLabelFilterChips';
import type { ColorLabel } from '../../services/feedback.service';
interface GallerySidebarProps { interface GallerySidebarProps {
isOpen: boolean; isOpen: boolean;
@@ -38,6 +40,11 @@ interface GallerySidebarProps {
likeCount?: number; likeCount?: number;
favoriteCount?: number; favoriteCount?: number;
ratedCount?: number; ratedCount?: number;
// Colour-label filters (#1044).
colorLabelsEnabled?: boolean;
activeColorFilters?: ColorLabel[];
onColorFilterChange?: (color: ColorLabel) => void;
colorLabelCounts?: Partial<Record<ColorLabel, number>>;
mediaFilter?: 'all' | 'photo' | 'video'; mediaFilter?: 'all' | 'photo' | 'video';
onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void; onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void;
showMediaFilter?: boolean; showMediaFilter?: boolean;
@@ -74,6 +81,10 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
likeCount = 0, likeCount = 0,
favoriteCount = 0, favoriteCount = 0,
ratedCount = 0, ratedCount = 0,
colorLabelsEnabled = false,
activeColorFilters = [],
onColorFilterChange,
colorLabelCounts = {},
mediaFilter = 'all', mediaFilter = 'all',
onMediaFilterChange, onMediaFilterChange,
showMediaFilter = false showMediaFilter = false
@@ -241,6 +252,15 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
className="w-full" className="w-full"
variant="compact" variant="compact"
/> />
{/* Colour filter (#1044) */}
{colorLabelsEnabled && onColorFilterChange && (
<ColorLabelFilterChips
className="mt-3"
activeColors={activeColorFilters}
onToggle={onColorFilterChange}
counts={colorLabelCounts}
/>
)}
</div> </div>
)} )}
@@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { differenceInDays, parseISO } from 'date-fns'; import { differenceInDays, parseISO } from 'date-fns';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -26,7 +26,7 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { api } from '../../config/api'; import { api } from '../../config/api';
import { Upload, Menu, Eye, EyeOff, Shield, X, Download } from 'lucide-react'; import { Upload, Menu, Eye, EyeOff, Shield, X, Download } from 'lucide-react';
import { galleryService } from '../../services/gallery.service'; import { galleryService } from '../../services/gallery.service';
import { feedbackService } from '../../services/feedback.service'; import { feedbackService, type ColorLabel } from '../../services/feedback.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings'; import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss'; import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
import { usePublicSettings } from '../../hooks/usePublicSettings'; import { usePublicSettings } from '../../hooks/usePublicSettings';
@@ -106,6 +106,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// Multi-select feedback filters (#889): OR-combined; empty = "All". // Multi-select feedback filters (#889): OR-combined; empty = "All".
// Clicking a filter toggles it, clicking "All" clears the set. // Clicking a filter toggles it, clicking "All" clears the set.
const [activeFilters, setActiveFilters] = useState<FeedbackFilterType[]>([]); const [activeFilters, setActiveFilters] = useState<FeedbackFilterType[]>([]);
// Colour-label filters (#1044) live in their own slice rather than as five
// more members of FeedbackFilterType: every exhaustive
// Record<FeedbackFilterType, …> in this file and the chip components would
// otherwise have to grow to ten keys.
const [activeColorFilters, setActiveColorFilters] = useState<ColorLabel[]>([]);
// People filter (#1074). Multi-select, AND by default — see the filter // People filter (#1074). Multi-select, AND by default — see the filter
// block below. `peopleMatchAny` only becomes reachable once a second // block below. `peopleMatchAny` only becomes reachable once a second
@@ -639,6 +644,16 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}); });
} }
// Apply colour-label filters (#1044). Guest-scoped by construction:
// `my_color_label` is the requesting viewer's own label, which is what a
// proofing client means by "show me my greens". Composes with (ANDs
// against) every filter above, like the people filter.
if (activeColorFilters.length > 0) {
photos = photos.filter(photo =>
!!photo.my_color_label && activeColorFilters.includes(photo.my_color_label as ColorLabel)
);
}
// Apply sorting // Apply sorting
// Each comparator defaults to its natural order (desc for dates/size/rating, asc for name). // Each comparator defaults to its natural order (desc for dates/size/rating, asc for name).
// The flip multiplier reverses that when sortDesc differs from the natural order. // The flip multiplier reverses that when sortDesc differs from the natural order.
@@ -679,7 +694,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
} }
return photos; return photos;
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, activeFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds, selectedPersonIds, peopleMatchAny]); }, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, activeFilters, activeColorFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds, selectedPersonIds, peopleMatchAny]);
// Counts shown in the filter chips ("Liked (N)", etc.). In guest // Counts shown in the filter chips ("Liked (N)", etc.). In guest
// mode these need to mirror the per-guest filter behaviour above — // mode these need to mirror the per-guest filter behaviour above —
@@ -703,6 +718,24 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
return data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0; return data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0;
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]); }, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
// Per-colour chip counts (#1044) — the viewer's own labels, matching what
// the filter actually selects.
const colorLabelCounts = useMemo(() => {
const counts: Partial<Record<ColorLabel, number>> = {};
for (const photo of data?.photos || []) {
const label = photo.my_color_label as ColorLabel | null | undefined;
if (!label) continue;
counts[label] = (counts[label] || 0) + 1;
}
return counts;
}, [data?.photos]);
const handleColorFilterToggle = useCallback((color: ColorLabel) => {
setActiveColorFilters(prev =>
prev.includes(color) ? prev.filter(c => c !== color) : [...prev, color]
);
}, []);
// Check if downloads are allowed (both event setting and not expired) // Check if downloads are allowed (both event setting and not expired)
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true); const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
@@ -1090,6 +1123,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
likeCount={likeCount} likeCount={likeCount}
favoriteCount={favoriteCount} favoriteCount={favoriteCount}
ratedCount={ratedCount} ratedCount={ratedCount}
colorLabelsEnabled={!!feedbackSettings?.allow_color_labels}
activeColorFilters={activeColorFilters}
onColorFilterChange={handleColorFilterToggle}
colorLabelCounts={colorLabelCounts}
/> />
) : null} ) : null}
@@ -1237,6 +1274,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
mediaFilter={mediaFilter} mediaFilter={mediaFilter}
onMediaFilterChange={setMediaFilter} onMediaFilterChange={setMediaFilter}
showMediaFilter={showMediaFilter} showMediaFilter={showMediaFilter}
colorLabelsEnabled={!!feedbackSettings?.allow_color_labels}
activeColorFilters={activeColorFilters}
onColorFilterChange={handleColorFilterToggle}
colorLabelCounts={colorLabelCounts}
/> />
</div> </div>
) : null} ) : null}
@@ -5,6 +5,7 @@ import { AuthenticatedImage } from '../common';
import { thumbnailUrlForTile } from './imageTiers'; import { thumbnailUrlForTile } from './imageTiers';
import { FeedbackIdentityModal } from './FeedbackIdentityModal'; import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { feedbackService } from '../../services/feedback.service'; import { feedbackService } from '../../services/feedback.service';
import { ColorLabelBadge } from './ColorLabelBadge';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext'; import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
import type { Photo } from '../../types'; import type { Photo } from '../../types';
@@ -378,6 +379,11 @@ export const PhotoCard: React.FC<PhotoCardProps> = ({
<> <>
<AuthenticatedImage {...imageProps} src={tileSrc} /> <AuthenticatedImage {...imageProps} src={tileSrc} />
{/* The guest's own colour label (#1044) visible without hovering
or opening anything, which is the point: the client watches
their selection progress across the grid. */}
<ColorLabelBadge colorLabel={photo.my_color_label} />
{beforeOverlay} {beforeOverlay}
{/* Hover Overlay */} {/* Hover Overlay */}
@@ -0,0 +1,183 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
feedbackService,
COLOR_LABELS,
COLOR_LABEL_SWATCHES,
type ColorLabel,
} from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoColorLabelsProps {
photoId: string;
gallerySlug: string;
/** The guest's current colour label, or null. */
myColorLabel: ColorLabel | null;
/** Per-colour visible counts, e.g. { green: 3 }. */
colorLabelCounts?: Partial<Record<ColorLabel, number>>;
isEnabled: boolean;
requireNameEmail?: boolean;
/** Keyboard hint to show under each swatch, e.g. { green: '1' }. */
shortcutHints?: Partial<Record<ColorLabel, string>>;
onColorLabelChange?: (label: ColorLabel | null) => void;
}
/**
* Colour-label picker (#1044): one label per guest per photo, changeable
* tapping the current colour removes it, tapping another switches. Same
* contract and identity handling as PhotoReactions; the labels are
* Lightroom's five colours so a selection round-trips into the catalogue.
*/
export const PhotoColorLabels: React.FC<PhotoColorLabelsProps> = ({
photoId,
gallerySlug,
myColorLabel,
colorLabelCounts = {},
isEnabled,
requireNameEmail = false,
shortcutHints = {},
onColorLabelChange
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const guestIdentity = useGuestIdentityOptional();
const [isSubmitting, setIsSubmitting] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const [pendingColor, setPendingColor] = useState<ColorLabel | null>(null);
const colorName = (color: ColorLabel) => t(`feedback.colorLabels.${color}`, color);
const submitColorLabelMutation = useMutation({
mutationFn: (data: { color: ColorLabel; guest_name?: string; guest_email?: string }) =>
feedbackService.submitFeedback(gallerySlug, photoId, {
feedback_type: 'color_label',
color_label: data.color,
guest_name: data.guest_name || undefined,
guest_email: data.guest_email || undefined
}),
onMutate: async (data) => {
setIsSubmitting(true);
// Optimistic update: the same colour toggles off, another switches. The
// PRE-mutation value travels via the mutation context — the onError
// closure sees the post-optimistic render, so reading `myColorLabel`
// there would "revert" to the already-wrong state.
const previousColor = myColorLabel;
if (onColorLabelChange) {
onColorLabelChange(data.color === previousColor ? null : data.color);
}
return { previousColor };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
},
onError: (_error, _data, context) => {
if (onColorLabelChange) {
onColorLabelChange(context?.previousColor ?? null); // revert optimistic update
}
toast.error(t('feedback.colorLabelError', 'Failed to update color label'));
},
onSettled: () => {
setIsSubmitting(false);
}
});
const handleColorClick = async (color: ColorLabel) => {
if (!isEnabled || isSubmitting) return;
// Guest identity mode: ensure a per-person guest token; the server reads
// name/email from the token — body values are ignored.
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return; // user cancelled the prompt
}
submitColorLabelMutation.mutate({ color });
return;
}
// Simple mode: legacy inline prompt flow.
if (requireNameEmail && !savedIdentity) {
setPendingColor(color);
setShowIdentityModal(true);
} else {
submitColorLabelMutation.mutate({
color,
...(savedIdentity ? { guest_name: savedIdentity.name, guest_email: savedIdentity.email } : {})
});
}
};
const handleIdentitySubmit = (name: string, email: string) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingColor) {
submitColorLabelMutation.mutate({ color: pendingColor, guest_name: name, guest_email: email });
setPendingColor(null);
}
};
if (!isEnabled) return null;
return (
<>
<div
className="flex flex-wrap items-center gap-1.5"
role="group"
aria-label={t('feedback.colorLabelsTitle', 'Color labels')}
>
{COLOR_LABELS.map((color) => {
const count = colorLabelCounts[color] || 0;
const isMine = myColorLabel === color;
const swatch = COLOR_LABEL_SWATCHES[color];
const shortcut = shortcutHints[color];
return (
<button
key={color}
type="button"
onClick={() => handleColorClick(color)}
disabled={isSubmitting}
className={`flex items-center gap-1.5 pl-1.5 pr-2.5 py-1.5 rounded-full text-sm transition-all ${
isMine
? 'bg-primary-100 dark:bg-primary-900/40 ring-1 ring-primary-500 scale-105'
: 'bg-surface text-muted-theme hover:bg-black/10 hover:scale-105'
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
aria-pressed={isMine}
// The colour is the only visual difference between these five
// buttons, so the name has to carry it for anyone who can't
// distinguish them.
aria-label={isMine
? t('feedback.removeColorLabel', 'Remove {{color}} label', { color: colorName(color) })
: t('feedback.setColorLabel', 'Mark as {{color}}', { color: colorName(color) })}
title={shortcut
? `${colorName(color)} (${shortcut})`
: colorName(color)}
>
<span
className="w-4 h-4 rounded-full border shrink-0"
style={{ backgroundColor: swatch.fill, borderColor: swatch.ring }}
aria-hidden="true"
/>
{shortcut && (
<span className="text-[10px] font-semibold opacity-70 leading-none" aria-hidden="true">
{shortcut}
</span>
)}
{count > 0 && <span className="font-medium">{count}</span>}
</button>
);
})}
</div>
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => { setShowIdentityModal(false); setPendingColor(null); }}
onSubmit={handleIdentitySubmit}
feedbackType={t('feedback.colorLabel', 'color label')}
/>
</>
);
};
@@ -3,6 +3,8 @@ import { Search, SortAsc, SortDesc, Grid, Heart, Star, MessageSquare, Bookmark }
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Input } from '../common'; import { Button, Input } from '../common';
import type { FilterType, FeedbackFilterType } from './GalleryFilter'; import type { FilterType, FeedbackFilterType } from './GalleryFilter';
import { ColorLabelFilterChips } from './ColorLabelFilterChips';
import type { ColorLabel } from '../../services/feedback.service';
interface PhotoCategory { interface PhotoCategory {
id: number | string; id: number | string;
@@ -40,6 +42,12 @@ interface PhotoFilterBarProps {
mediaFilter?: 'all' | 'photo' | 'video'; mediaFilter?: 'all' | 'photo' | 'video';
onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void; onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void;
showMediaFilter?: boolean; showMediaFilter?: boolean;
// Colour-label filters (#1044). Their own slice rather than more members
// of FilterType — see the note in GalleryView.
colorLabelsEnabled?: boolean;
activeColorFilters?: ColorLabel[];
onColorFilterChange?: (color: ColorLabel) => void;
colorLabelCounts?: Partial<Record<ColorLabel, number>>;
} }
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
@@ -59,7 +67,11 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
onFilterChange, onFilterChange,
mediaFilter = 'all', mediaFilter = 'all',
onMediaFilterChange, onMediaFilterChange,
showMediaFilter = false showMediaFilter = false,
colorLabelsEnabled = false,
activeColorFilters = [],
onColorFilterChange,
colorLabelCounts = {}
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showSortMenu, setShowSortMenu] = useState(false); const [showSortMenu, setShowSortMenu] = useState(false);
@@ -291,6 +303,16 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
</div> </div>
)} )}
{/* Colour filter (#1044), desktop */}
{feedbackEnabled && colorLabelsEnabled && onColorFilterChange && (
<ColorLabelFilterChips
className="hidden lg:flex flex-shrink-0"
activeColors={activeColorFilters}
onToggle={onColorFilterChange}
counts={colorLabelCounts}
/>
)}
{/* Without categories this row only carries desktop content (the {/* Without categories this row only carries desktop content (the
chips are lg-only; mobile has its own block below), so hide chips are lg-only; mobile has its own block below), so hide
the count below lg to keep the mobile layout unchanged. */} the count below lg to keep the mobile layout unchanged. */}
@@ -389,6 +411,16 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
</div> </div>
</div> </div>
)} )}
{/* Colour filter (#1044), mobile/tablet */}
{feedbackEnabled && colorLabelsEnabled && onColorFilterChange && (
<ColorLabelFilterChips
className="flex lg:hidden"
activeColors={activeColorFilters}
onToggle={onColorFilterChange}
counts={colorLabelCounts}
/>
)}
</div> </div>
</div> </div>
); );
@@ -7,7 +7,9 @@ import { useSavePhotoToDevice } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common'; import { AuthenticatedImage } from '../common';
import { PhotoFeedback } from './PhotoFeedback'; import { PhotoFeedback } from './PhotoFeedback';
import { previewUrlForViewport } from './imageTiers'; import { previewUrlForViewport } from './imageTiers';
import { feedbackService } from '../../services/feedback.service'; import { feedbackService, type ColorLabel, type KeybindMode } from '../../services/feedback.service';
import { PhotoColorLabels } from './PhotoColorLabels';
import { resolveFeedbackKey, colorShortcutHints } from '../../utils/feedbackKeybinds';
import { galleryService } from '../../services/gallery.service'; import { galleryService } from '../../services/gallery.service';
import { FeedbackIdentityModal } from './FeedbackIdentityModal'; import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { VideoPlayer } from './VideoPlayer'; import { VideoPlayer } from './VideoPlayer';
@@ -93,19 +95,25 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
allow_ratings?: boolean; allow_ratings?: boolean;
allow_comments?: boolean; allow_comments?: boolean;
allow_reactions?: boolean; allow_reactions?: boolean;
allow_color_labels?: boolean;
keybind_mode?: KeybindMode;
show_feedback_to_guests?: boolean; show_feedback_to_guests?: boolean;
require_name_email?: boolean; require_name_email?: boolean;
} | null>(null); } | null>(null);
const [myLiked, setMyLiked] = useState<boolean>(false); const [myLiked, setMyLiked] = useState<boolean>(false);
const [myRating, setMyRating] = useState<number>(0); const [myRating, setMyRating] = useState<number>(0);
const [myColorLabel, setMyColorLabel] = useState<ColorLabel | null>(null);
const [colorLabelCounts, setColorLabelCounts] = useState<Partial<Record<ColorLabel, number>>>({});
const [likeCount, setLikeCount] = useState<number>(0); const [likeCount, setLikeCount] = useState<number>(0);
const [avgRating, setAvgRating] = useState<number>(0); const [avgRating, setAvgRating] = useState<number>(0);
const [totalRatings, setTotalRatings] = useState<number>(0); const [totalRatings, setTotalRatings] = useState<number>(0);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const [showIdentityModal, setShowIdentityModal] = useState(false); const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null); const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating' | 'color_label'; rating?: number; color?: ColorLabel }>(null);
const guestIdentity = useGuestIdentityOptional(); const guestIdentity = useGuestIdentityOptional();
const isGuestMode = guestIdentity?.identityMode === 'guest'; const isGuestMode = guestIdentity?.identityMode === 'guest';
// Which shortcut scheme this gallery uses (#1044).
const keybindMode: KeybindMode = feedbackSettings?.keybind_mode || 'colors';
// Per-guest cap modal (#655) — shared across every submitFeedback call site // Per-guest cap modal (#655) — shared across every submitFeedback call site
// in the lightbox (guest mode, simple mode, identity-modal-confirm path). // in the lightbox (guest mode, simple mode, identity-modal-confirm path).
const { modal: limitModal, handleError: handleLimitError } = useFeedbackLimitModal(); const { modal: limitModal, handleError: handleLimitError } = useFeedbackLimitModal();
@@ -224,6 +232,21 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}; };
}, [disableRightClick]); }, [disableRightClick]);
// The keydown effect below is registered with [currentIndex] deps, so its
// closure would still hold the settings from the moment the lightbox
// opened — i.e. `null`, since they load asynchronously, leaving every
// proofing shortcut dead until the user changed photo. A ref refreshed on
// every render keeps the handler reading current state without
// re-registering the listener on each keystroke's worth of state change.
const proofingRef = useRef({
feedbackEnabled: false,
allowColorLabels: false,
allowRatings: false,
keybindMode: 'colors' as KeybindMode,
myRating: 0,
submitColorLabel: (async () => {}) as (color: ColorLabel | null) => Promise<void>,
submitRating: (async () => {}) as (value: number) => Promise<void>,
});
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) { switch (e.key) {
@@ -250,6 +273,30 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
handleDownload(); handleDownload();
} }
break; break;
default: {
// Proofing shortcuts (#1044). Resolved from the event's keybind
// scheme so 1/2/3 mean colours in colour-only mode and stars in
// Lightroom mode; the helper ignores modified keys and anything
// typed into a field.
const proofing = proofingRef.current;
if (!proofing.feedbackEnabled) break;
const action = resolveFeedbackKey(e, {
mode: proofing.keybindMode,
allowColorLabels: proofing.allowColorLabels,
allowRatings: proofing.allowRatings,
});
if (!action) break;
e.preventDefault();
if (action.type === 'color') {
void proofing.submitColorLabel(action.color);
} else if (action.type === 'rating') {
// Pressing the current rating again clears it (#884).
void proofing.submitRating(action.value === proofing.myRating ? 0 : action.value);
} else {
void proofing.submitColorLabel(null);
}
break;
}
} }
}; };
@@ -296,6 +343,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
if (!mounted) return; if (!mounted) return;
setMyLiked(!!data.my_feedback.liked); setMyLiked(!!data.my_feedback.liked);
setMyRating(data.my_feedback.rating || 0); setMyRating(data.my_feedback.rating || 0);
setMyColorLabel((data.my_feedback.color_label as ColorLabel) || null);
setColorLabelCounts(data.color_labels || {});
setLikeCount(Number(data.summary?.like_count) || 0); setLikeCount(Number(data.summary?.like_count) || 0);
setAvgRating(Number(data.summary?.average_rating) || 0); setAvgRating(Number(data.summary?.average_rating) || 0);
setTotalRatings(Number(data.summary?.total_ratings) || 0); setTotalRatings(Number(data.summary?.total_ratings) || 0);
@@ -417,6 +466,72 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
if (onFeedbackChange) onFeedbackChange(); if (onFeedbackChange) onFeedbackChange();
}; };
/**
* Set / switch / clear the guest's colour label (#1044). Same identity
* handling as submitLike above; `null` means "clear", which the backend
* expresses as submitting the current colour again.
*/
const submitColorLabel = async (color: ColorLabel | null) => {
if (!feedbackSettings?.allow_color_labels) return;
// Clearing means re-submitting the current colour — the backend toggles
// a repeat submission off. With nothing set there is nothing to clear.
const value = color ?? myColorLabel;
if (!value) return;
const willBeSet = value === myColorLabel ? null : value;
if (isGuestMode && guestIdentity) {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
try {
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
feedback_type: 'color_label',
color_label: value,
});
setMyColorLabel(willBeSet);
if (onFeedbackChange) onFeedbackChange();
} catch (err) {
if (handleLimitError(err)) return;
console.warn('Color label submit failed', err);
}
return;
}
const needIdentity = feedbackSettings?.require_name_email && !savedIdentity;
if (needIdentity) {
setPendingAction({ type: 'color_label', color: value });
setShowIdentityModal(true);
return;
}
try {
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
feedback_type: 'color_label',
color_label: value,
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
setMyColorLabel(willBeSet);
if (onFeedbackChange) onFeedbackChange();
} catch (err) {
if (handleLimitError(err)) return;
console.warn('Color label submit failed', err);
}
};
// Refreshed on every render (see the ref's declaration above): the keydown
// listener reads current settings and handlers without being re-registered.
proofingRef.current = {
feedbackEnabled: !!feedbackEnabled && !!feedbackSettings?.feedback_enabled,
allowColorLabels: !!feedbackSettings?.allow_color_labels,
allowRatings: !!feedbackSettings?.allow_ratings,
keybindMode,
myRating,
submitColorLabel,
submitRating,
};
const goToPrevious = () => { const goToPrevious = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1)); setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
resetZoom(); resetZoom();
@@ -927,6 +1042,27 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
</div> </div>
)} )}
{/* Inline color labels (#1044). In the toolbar rather than the
feedback panel: the whole point is a fast keyboard/click
proofing pass, which a panel toggle would interrupt. */}
{feedbackEnabled && feedbackSettings?.allow_color_labels && (
<div className="flex items-center gap-1 ml-1">
<PhotoColorLabels
photoId={String(currentPhoto.id)}
gallerySlug={slug}
myColorLabel={myColorLabel}
colorLabelCounts={feedbackSettings?.show_feedback_to_guests ? colorLabelCounts : {}}
isEnabled
requireNameEmail={!!feedbackSettings?.require_name_email}
shortcutHints={colorShortcutHints(keybindMode)}
onColorLabelChange={(label) => {
setMyColorLabel(label);
if (onFeedbackChange) onFeedbackChange();
}}
/>
</div>
)}
{/* Feedback button with indicator. Likes/ratings have their {/* Feedback button with indicator. Likes/ratings have their
own dedicated toolbar buttons above, so this panel toggle own dedicated toolbar buttons above, so this panel toggle
only has work to do when comments (#518) or the emoji only has work to do when comments (#518) or the emoji
@@ -1197,13 +1333,29 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
setAvgRating(Number(fresh.summary?.average_rating) || 0); setAvgRating(Number(fresh.summary?.average_rating) || 0);
setTotalRatings(Number(fresh.summary?.total_ratings) || 0); setTotalRatings(Number(fresh.summary?.total_ratings) || 0);
} catch {} } catch {}
} else if (pendingAction?.type === 'color_label' && pendingAction.color) {
try {
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
feedback_type: 'color_label',
color_label: pendingAction.color,
guest_name: name,
guest_email: email,
});
setMyColorLabel(pendingAction.color === myColorLabel ? null : pendingAction.color);
} catch (err) {
if (!handleLimitError(err)) throw err;
}
} }
// Sync gallery photo list (feedback filter chips) — parity with // Sync gallery photo list (feedback filter chips) — parity with
// the direct submit paths. // the direct submit paths.
if (onFeedbackChange) onFeedbackChange(); if (onFeedbackChange) onFeedbackChange();
setPendingAction(null); setPendingAction(null);
}} }}
feedbackType={pendingAction?.type === 'rating' ? 'rating' : 'like'} feedbackType={
pendingAction?.type === 'rating' ? 'rating'
: pendingAction?.type === 'color_label' ? 'color label'
: 'like'
}
/> />
{/* Per-guest cap modal (#655). Single instance fires for any of the {/* Per-guest cap modal (#655). Single instance fires for any of the
lightbox's submitFeedback paths via the shared hook. */} lightbox's submitFeedback paths via the shared hook. */}
@@ -10,6 +10,7 @@ import Download from 'yet-another-react-lightbox/plugins/download';
import Captions from 'yet-another-react-lightbox/plugins/captions'; import Captions from 'yet-another-react-lightbox/plugins/captions';
import 'yet-another-react-lightbox/styles.css'; import 'yet-another-react-lightbox/styles.css';
import 'yet-another-react-lightbox/plugins/thumbnails.css'; import 'yet-another-react-lightbox/plugins/thumbnails.css';
import { ColorLabelBadge } from '../ColorLabelBadge';
import 'yet-another-react-lightbox/plugins/captions.css'; import 'yet-another-react-lightbox/plugins/captions.css';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Download as DownloadIcon, Heart, Check, Star, MessageSquare, Package, LogOut } from 'lucide-react'; import { Download as DownloadIcon, Heart, Check, Star, MessageSquare, Package, LogOut } from 'lucide-react';
@@ -130,6 +131,9 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'} useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/> />
{/* Colour label (#1044) — same badge every layout uses. */}
<ColorLabelBadge colorLabel={photo.my_color_label} />
{/* Overlay Gradient */} {/* Overlay Gradient */}
<div className="gallery-premium-photo-overlay" /> <div className="gallery-premium-photo-overlay" />
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Heart } from 'lucide-react'; import { Heart } from 'lucide-react';
import { AuthenticatedImage } from '../../../common'; import { AuthenticatedImage } from '../../../common';
import { ColorLabelBadge } from '../../ColorLabelBadge';
import type { Photo } from '../../../../types'; import type { Photo } from '../../../../types';
interface StoryPhotoCardProps { interface StoryPhotoCardProps {
@@ -78,6 +79,9 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
/> />
</a> </a>
{/* Colour label (#1044) — same badge every layout uses. */}
<ColorLabelBadge colorLabel={photo.my_color_label} />
{/* Overlay */} {/* Overlay */}
<div className="story-photo-card-overlay" /> <div className="story-photo-card-overlay" />
@@ -89,6 +89,15 @@ export interface EventSettings {
event_require_expiration: boolean; event_require_expiration: boolean;
event_default_require_password: boolean; event_default_require_password: boolean;
event_default_feedback_enabled: boolean; event_default_feedback_enabled: boolean;
// Per-type guest-feedback defaults for new galleries (#1044). These are
// DEFAULTS: changing one never touches a gallery that already exists.
event_default_allow_ratings: boolean;
event_default_allow_likes: boolean;
event_default_allow_favorites: boolean;
event_default_allow_comments: boolean;
event_default_allow_reactions: boolean;
event_default_allow_color_labels: boolean;
event_default_keybind_mode: 'colors' | 'lightroom';
gallery_show_filter_bar: boolean; gallery_show_filter_bar: boolean;
event_phone_field_enabled: boolean; event_phone_field_enabled: boolean;
} }
@@ -177,6 +186,13 @@ export function useSettingsState() {
event_require_expiration: true, event_require_expiration: true,
event_default_require_password: true, event_default_require_password: true,
event_default_feedback_enabled: false, event_default_feedback_enabled: false,
event_default_allow_ratings: true,
event_default_allow_likes: true,
event_default_allow_favorites: true,
event_default_allow_comments: true,
event_default_allow_reactions: true,
event_default_allow_color_labels: false,
event_default_keybind_mode: 'colors',
gallery_show_filter_bar: true, gallery_show_filter_bar: true,
event_phone_field_enabled: false event_phone_field_enabled: false
}); });
@@ -285,6 +301,15 @@ export function useSettingsState() {
event_require_expiration: toBoolean(settings.event_require_expiration, true), event_require_expiration: toBoolean(settings.event_require_expiration, true),
event_default_require_password: toBoolean(settings.event_default_require_password, true), event_default_require_password: toBoolean(settings.event_default_require_password, true),
event_default_feedback_enabled: toBoolean(settings.event_default_feedback_enabled, false), event_default_feedback_enabled: toBoolean(settings.event_default_feedback_enabled, false),
// Fallbacks mirror FEEDBACK_TOGGLES in backend
// services/feedbackDefaults.js — keep the two in step.
event_default_allow_ratings: toBoolean(settings.event_default_allow_ratings, true),
event_default_allow_likes: toBoolean(settings.event_default_allow_likes, true),
event_default_allow_favorites: toBoolean(settings.event_default_allow_favorites, true),
event_default_allow_comments: toBoolean(settings.event_default_allow_comments, true),
event_default_allow_reactions: toBoolean(settings.event_default_allow_reactions, true),
event_default_allow_color_labels: toBoolean(settings.event_default_allow_color_labels, false),
event_default_keybind_mode: settings.event_default_keybind_mode === 'lightroom' ? 'lightroom' : 'colors',
gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true), gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true),
event_phone_field_enabled: toBoolean(settings.event_phone_field_enabled, false) event_phone_field_enabled: toBoolean(settings.event_phone_field_enabled, false)
}); });
@@ -3,6 +3,7 @@ import { Save, AlertCircle } from 'lucide-react';
import { Button, Card } from '../../../components/common'; import { Button, Card } from '../../../components/common';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { EventSettings } from '../hooks/useSettingsState'; import type { EventSettings } from '../hooks/useSettingsState';
import { COLOR_LABEL_SWATCHES, COLOR_LABELS } from '../../../services/feedback.service';
interface EventsTabProps { interface EventsTabProps {
eventSettings: EventSettings; eventSettings: EventSettings;
@@ -13,6 +14,25 @@ interface EventsTabProps {
}; };
} }
/**
* The per-type feedback defaults, in the order the per-event panel shows
* them. Mirrors FEEDBACK_TOGGLES in backend services/feedbackDefaults.js.
*/
const FEEDBACK_TYPE_DEFAULTS: Array<{
key: 'event_default_allow_ratings' | 'event_default_allow_likes'
| 'event_default_allow_favorites' | 'event_default_allow_comments'
| 'event_default_allow_reactions' | 'event_default_allow_color_labels';
label: string;
fallback: string;
}> = [
{ key: 'event_default_allow_ratings', label: 'settings.events.defaultAllowRatings', fallback: 'Star ratings' },
{ key: 'event_default_allow_likes', label: 'settings.events.defaultAllowLikes', fallback: 'Likes' },
{ key: 'event_default_allow_favorites', label: 'settings.events.defaultAllowFavorites', fallback: 'Favourites' },
{ key: 'event_default_allow_comments', label: 'settings.events.defaultAllowComments', fallback: 'Comments' },
{ key: 'event_default_allow_reactions', label: 'settings.events.defaultAllowReactions', fallback: 'Emoji reactions' },
{ key: 'event_default_allow_color_labels', label: 'settings.events.defaultAllowColorLabels', fallback: 'Color labels' },
];
export const EventsTab: React.FC<EventsTabProps> = ({ export const EventsTab: React.FC<EventsTabProps> = ({
eventSettings, eventSettings,
setEventSettings, setEventSettings,
@@ -188,6 +208,79 @@ export const EventsTab: React.FC<EventsTabProps> = ({
</label> </label>
</div> </div>
{/* Per-type guest-feedback defaults (#1044). Defaults for NEW
galleries existing galleries keep whatever they were created
with, so flipping one here can never change a gallery a client
is in the middle of. Greyed out rather than hidden while the
master default is off, so the options stay discoverable. */}
<div
className={`ml-7 pl-4 border-l border-neutral-200 dark:border-neutral-700 space-y-3 ${
eventSettings.event_default_feedback_enabled ? '' : 'opacity-50'
}`}
>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t(
'settings.events.feedbackTypeDefaultsHelp',
'Which feedback types new galleries start with. Existing galleries are not affected — each gallery can still be changed individually.'
)}
</p>
{FEEDBACK_TYPE_DEFAULTS.map(({ key, label, fallback }) => (
<label key={key} className="flex items-start gap-3">
<input
type="checkbox"
disabled={!eventSettings.event_default_feedback_enabled}
checked={eventSettings[key]}
onChange={(e) => setEventSettings(prev => ({ ...prev, [key]: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="text-sm text-neutral-700 dark:text-neutral-300 flex items-center gap-2">
{t(label, fallback)}
{key === 'event_default_allow_color_labels' && (
<span className="flex items-center gap-1" aria-hidden="true">
{COLOR_LABELS.map((color) => (
<span
key={color}
className="w-3 h-3 rounded-full border"
style={{
backgroundColor: COLOR_LABEL_SWATCHES[color].fill,
borderColor: COLOR_LABEL_SWATCHES[color].ring,
}}
/>
))}
</span>
)}
</span>
</label>
))}
<div>
<label
className="block text-sm text-neutral-700 dark:text-neutral-300 mb-1"
htmlFor="event_default_keybind_mode"
>
{t('settings.events.defaultKeybindMode', 'Default lightbox shortcuts')}
</label>
<select
id="event_default_keybind_mode"
disabled={!eventSettings.event_default_feedback_enabled}
value={eventSettings.event_default_keybind_mode}
onChange={(e) => setEventSettings(prev => ({
...prev,
event_default_keybind_mode: e.target.value === 'lightroom' ? 'lightroom' : 'colors',
}))}
className="w-full max-w-sm px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm"
>
<option value="colors">
{t('settings.events.keybindColors', 'Colors only — 1 green, 2 yellow, 3 red')}
</option>
<option value="lightroom">
{t('settings.events.keybindLightroom', 'Lightroom — 1-5 stars, 6-9 colors')}
</option>
</select>
</div>
</div>
<div> <div>
<label className="flex items-start gap-3"> <label className="flex items-start gap-3">
<input <input
+58 -9
View File
@@ -1084,7 +1084,9 @@
"privacyNote": "Personen werden automatisch innerhalb dieser Galerie erkannt. Es werden keine Daten an externe Dienste gesendet.", "privacyNote": "Personen werden automatisch innerhalb dieser Galerie erkannt. Es werden keine Daten an externe Dienste gesendet.",
"inThisPhoto": "Auf diesem Foto:", "inThisPhoto": "Auf diesem Foto:",
"downloadThese": "Diese {{count}} herunterladen" "downloadThese": "Diese {{count}} herunterladen"
} },
"colorFilter": "Farbe",
"filterByColor": "Nur {{color}} anzeigen"
}, },
"categories": { "categories": {
"title": "Fotokategorien", "title": "Fotokategorien",
@@ -1659,7 +1661,17 @@
"enablePhoneField": "Telefonnummer-Feld aktivieren", "enablePhoneField": "Telefonnummer-Feld aktivieren",
"enablePhoneFieldHelp": "Fügt ein optionales Telefonnummer-Eingabefeld zum Veranstaltungsformular hinzu. Nützlich für Folgeautomatisierungen wie WhatsApp-Zustellung via n8n. Immer optional, auch wenn aktiviert.", "enablePhoneFieldHelp": "Fügt ein optionales Telefonnummer-Eingabefeld zum Veranstaltungsformular hinzu. Nützlich für Folgeautomatisierungen wie WhatsApp-Zustellung via n8n. Immer optional, auch wenn aktiviert.",
"defaultFeedbackEnabled": "Gäste-Feedback standardmäßig aktivieren", "defaultFeedbackEnabled": "Gäste-Feedback standardmäßig aktivieren",
"defaultFeedbackEnabledHelp": "Vorbelegt \"Gäste-Feedback\" beim Erstellen neuer Events. Einzelne Feedback-Optionen (Likes, Bewertungen, Kommentare) bleiben pro Event anpassbar." "defaultFeedbackEnabledHelp": "Vorbelegt \"Gäste-Feedback\" beim Erstellen neuer Events. Einzelne Feedback-Optionen (Likes, Bewertungen, Kommentare) bleiben pro Event anpassbar.",
"feedbackTypeDefaultsHelp": "Welche Feedback-Typen neue Galerien standardmäßig erhalten. Bestehende Galerien bleiben unverändert jede Galerie lässt sich weiterhin einzeln anpassen.",
"defaultAllowRatings": "Sternebewertung",
"defaultAllowLikes": "Likes",
"defaultAllowFavorites": "Favoriten",
"defaultAllowComments": "Kommentare",
"defaultAllowReactions": "Emoji-Reaktionen",
"defaultAllowColorLabels": "Farbmarkierungen",
"defaultKeybindMode": "Standard-Tastenkürzel in der Großansicht",
"keybindColors": "Nur Farben 1 Grün, 2 Gelb, 3 Rot",
"keybindLightroom": "Lightroom 15 Sterne, 69 Farben"
}, },
"imageSecurity": { "imageSecurity": {
"title": "Bildschutz", "title": "Bildschutz",
@@ -2881,7 +2893,15 @@
"views": "Aufrufe", "views": "Aufrufe",
"downloads": "Downloads", "downloads": "Downloads",
"likes": "Likes" "likes": "Likes"
} },
"myMarks": "Deine Markierungen",
"myMarksHelp": "Nur für dich sichtbar. Sie erscheinen nie in der Kundengalerie und werden als XMP nach Lightroom exportiert.",
"myRating": "Deine Bewertung",
"clearRating": "Deine Bewertung entfernen",
"rateStars": "Mit {{count}} Sternen bewerten",
"markError": "Markierung konnte nicht gespeichert werden",
"yourMarkColor": "Deine Markierung: {{color}}",
"yourMarkRating": "Deine Bewertung: {{count}}"
}, },
"events": { "events": {
"tabs": { "tabs": {
@@ -2937,7 +2957,8 @@
"comments": "Kommentare", "comments": "Kommentare",
"ratings": "Bewertungen", "ratings": "Bewertungen",
"reactions": "Reaktionen", "reactions": "Reaktionen",
"lastSeen": "Zuletzt gesehen" "lastSeen": "Zuletzt gesehen",
"colorLabels": "Farbmarkierungen"
}, },
"view": "Details anzeigen", "view": "Details anzeigen",
"export": "Exportieren", "export": "Exportieren",
@@ -2951,7 +2972,8 @@
"favorited": "Favorisiert", "favorited": "Favorisiert",
"rated": "Bewertet", "rated": "Bewertet",
"reacted": "Reagiert", "reacted": "Reagiert",
"commented": "Kommentiert" "commented": "Kommentiert",
"labeled": "Farbmarkierungen"
}, },
"inviteStatus": { "inviteStatus": {
"pending": "Ausstehend", "pending": "Ausstehend",
@@ -3682,7 +3704,14 @@
"enableRateLimiting": "Ratenbegrenzung aktivieren", "enableRateLimiting": "Ratenbegrenzung aktivieren",
"rateLimitingDesc": "Spam durch Begrenzung der Feedback-Häufigkeit verhindern", "rateLimitingDesc": "Spam durch Begrenzung der Feedback-Häufigkeit verhindern",
"timeWindow": "Zeitfenster (Minuten)", "timeWindow": "Zeitfenster (Minuten)",
"maxRequests": "Maximale Anfragen" "maxRequests": "Maximale Anfragen",
"colorLabels": "Farbmarkierungen",
"colorLabelsDesc": "Eine Farbe pro Gast und Foto im Farbschema von Lightroom, damit die Auswahl per XMP übernommen werden kann",
"keybindMode": "Tastenkürzel",
"keybindColors": "Nur Farben (am einfachsten)",
"keybindColorsDesc": "1 = Grün (1. Wahl), 2 = Gelb (2. Wahl), 3 = Rot (aussortiert)",
"keybindLightroom": "Lightroom-Standard",
"keybindLightroomDesc": "15 vergeben Sterne, 69 setzen Rot / Gelb / Grün / Blau"
}, },
"settingsUpdated": "Feedback-Einstellungen aktualisiert", "settingsUpdated": "Feedback-Einstellungen aktualisiert",
"settingsUpdateError": "Einstellungen konnten nicht aktualisiert werden", "settingsUpdateError": "Einstellungen konnten nicht aktualisiert werden",
@@ -3768,7 +3797,20 @@
"viewAllFeedback": "Gesamtes Feedback & Einstellungen anzeigen", "viewAllFeedback": "Gesamtes Feedback & Einstellungen anzeigen",
"exportShapeLabel": "Form", "exportShapeLabel": "Form",
"exportShapeLong": "Pro Aktion (lang)", "exportShapeLong": "Pro Aktion (lang)",
"exportShapePivot": "Pro Gast (pivot)" "exportShapePivot": "Pro Gast (pivot)",
"colorLabels": {
"red": "Rot",
"yellow": "Gelb",
"green": "Grün",
"blue": "Blau",
"purple": "Violett"
},
"colorLabel": "Farbmarkierung",
"colorLabelsTitle": "Farbmarkierungen",
"colorLabelError": "Farbmarkierung konnte nicht gespeichert werden",
"removeColorLabel": "Markierung {{color}} entfernen",
"setColorLabel": "Als {{color}} markieren",
"markedAs": "Markiert als {{color}}"
}, },
"filter": { "filter": {
"feedbackFilters": "Feedback-Filter", "feedbackFilters": "Feedback-Filter",
@@ -3786,7 +3828,11 @@
"twoStarsPlus": "2+ Sterne", "twoStarsPlus": "2+ Sterne",
"threeStarsPlus": "3+ Sterne", "threeStarsPlus": "3+ Sterne",
"fourStarsPlus": "4+ Sterne", "fourStarsPlus": "4+ Sterne",
"fiveStarsOnly": "Nur 5 Sterne" "fiveStarsOnly": "Nur 5 Sterne",
"colorLabels": "Farbmarkierungen",
"showOnlyColor": "Nur {{color}} anzeigen",
"myColorLabels": "Deine Markierungen",
"showOnlyMyColor": "Nur meine Markierungen in {{color}} anzeigen"
}, },
"export": { "export": {
"button": "Exportieren", "button": "Exportieren",
@@ -3806,7 +3852,10 @@
"copied": "In die Zwischenablage kopiert.", "copied": "In die Zwischenablage kopiert.",
"copyFailed": "Zugriff auf die Zwischenablage blockiert. Bitte den Text markieren und manuell kopieren.", "copyFailed": "Zugriff auf die Zwischenablage blockiert. Bitte den Text markieren und manuell kopieren.",
"download": "Als Datei herunterladen" "download": "Als Datei herunterladen"
} },
"markSource": "XMP-Sterne & -Farbe aus",
"markSourceClient": "Kundenauswahl",
"markSourceMine": "Deine Markierungen"
}, },
"slideshow": { "slideshow": {
"adminTitle": "Live-Diashow", "adminTitle": "Live-Diashow",
+58 -9
View File
@@ -625,7 +625,9 @@
"privacyNote": "People are detected automatically inside this gallery. Nothing is sent to any external service.", "privacyNote": "People are detected automatically inside this gallery. Nothing is sent to any external service.",
"inThisPhoto": "In this photo:", "inThisPhoto": "In this photo:",
"downloadThese": "Download these {{count}}" "downloadThese": "Download these {{count}}"
} },
"colorFilter": "Color",
"filterByColor": "Show only {{color}}"
}, },
"categories": { "categories": {
"title": "Photo Categories", "title": "Photo Categories",
@@ -1263,7 +1265,17 @@
"enablePhoneField": "Enable phone number field", "enablePhoneField": "Enable phone number field",
"enablePhoneFieldHelp": "Adds an optional phone number input to the event form. Useful for downstream automations like WhatsApp delivery via n8n. Always optional even when enabled.", "enablePhoneFieldHelp": "Adds an optional phone number input to the event form. Useful for downstream automations like WhatsApp delivery via n8n. Always optional even when enabled.",
"defaultFeedbackEnabled": "Enable Guest Feedback by default", "defaultFeedbackEnabled": "Enable Guest Feedback by default",
"defaultFeedbackEnabledHelp": "Pre-check \"Guest Feedback\" when creating new events. Individual feedback options (likes, ratings, comments) can still be customised per event." "defaultFeedbackEnabledHelp": "Pre-check \"Guest Feedback\" when creating new events. Individual feedback options (likes, ratings, comments) can still be customised per event.",
"feedbackTypeDefaultsHelp": "Which feedback types new galleries start with. Existing galleries are not affected — each gallery can still be changed individually.",
"defaultAllowRatings": "Star ratings",
"defaultAllowLikes": "Likes",
"defaultAllowFavorites": "Favorites",
"defaultAllowComments": "Comments",
"defaultAllowReactions": "Emoji reactions",
"defaultAllowColorLabels": "Color labels",
"defaultKeybindMode": "Default lightbox shortcuts",
"keybindColors": "Colors only — 1 green, 2 yellow, 3 red",
"keybindLightroom": "Lightroom — 1-5 stars, 6-9 colors"
}, },
"imageSecurity": { "imageSecurity": {
"title": "Image Protection", "title": "Image Protection",
@@ -2453,7 +2465,15 @@
"views": "Views", "views": "Views",
"downloads": "Downloads", "downloads": "Downloads",
"likes": "Likes" "likes": "Likes"
} },
"myMarks": "Your marks",
"myMarksHelp": "Only you see these. They never appear in the client gallery, and they export to Lightroom as XMP.",
"myRating": "Your rating",
"clearRating": "Clear your rating",
"rateStars": "Rate {{count}} stars",
"markError": "Failed to save your mark",
"yourMarkColor": "Your mark: {{color}}",
"yourMarkRating": "Your rating: {{count}}"
}, },
"events": { "events": {
"tabs": { "tabs": {
@@ -2509,7 +2529,8 @@
"comments": "Comments", "comments": "Comments",
"ratings": "Ratings", "ratings": "Ratings",
"reactions": "Reactions", "reactions": "Reactions",
"lastSeen": "Last seen" "lastSeen": "Last seen",
"colorLabels": "Color labels"
}, },
"view": "View details", "view": "View details",
"export": "Export", "export": "Export",
@@ -2523,7 +2544,8 @@
"favorited": "Favorited", "favorited": "Favorited",
"rated": "Rated", "rated": "Rated",
"reacted": "Reacted", "reacted": "Reacted",
"commented": "Commented" "commented": "Commented",
"labeled": "Color labels"
}, },
"inviteStatus": { "inviteStatus": {
"pending": "Pending", "pending": "Pending",
@@ -3703,7 +3725,14 @@
"enableRateLimiting": "Enable Rate Limiting", "enableRateLimiting": "Enable Rate Limiting",
"rateLimitingDesc": "Prevent spam by limiting feedback frequency", "rateLimitingDesc": "Prevent spam by limiting feedback frequency",
"timeWindow": "Time Window (minutes)", "timeWindow": "Time Window (minutes)",
"maxRequests": "Max Requests" "maxRequests": "Max Requests",
"colorLabels": "Color Labels",
"colorLabelsDesc": "One color per guest per photo, using Lightroom's color set so selections carry over via XMP",
"keybindMode": "Keyboard shortcuts",
"keybindColors": "Colors only (simplest)",
"keybindColorsDesc": "1 = green (1st choice), 2 = yellow (2nd choice), 3 = red (rejected)",
"keybindLightroom": "Lightroom defaults",
"keybindLightroomDesc": "1-5 set the star rating, 6-9 set red / yellow / green / blue"
}, },
"settingsUpdated": "Feedback settings updated", "settingsUpdated": "Feedback settings updated",
"settingsUpdateError": "Failed to update settings", "settingsUpdateError": "Failed to update settings",
@@ -3789,7 +3818,20 @@
"viewAllFeedback": "View all feedback & settings", "viewAllFeedback": "View all feedback & settings",
"exportShapeLabel": "Shape", "exportShapeLabel": "Shape",
"exportShapeLong": "Per-action (long)", "exportShapeLong": "Per-action (long)",
"exportShapePivot": "Per-guest (pivot)" "exportShapePivot": "Per-guest (pivot)",
"colorLabels": {
"red": "Red",
"yellow": "Yellow",
"green": "Green",
"blue": "Blue",
"purple": "Purple"
},
"colorLabel": "color label",
"colorLabelsTitle": "Color labels",
"colorLabelError": "Failed to update color label",
"removeColorLabel": "Remove {{color}} label",
"setColorLabel": "Mark as {{color}}",
"markedAs": "Marked as {{color}}"
}, },
"filter": { "filter": {
"feedbackFilters": "Feedback Filters", "feedbackFilters": "Feedback Filters",
@@ -3807,7 +3849,11 @@
"twoStarsPlus": "2+ Stars", "twoStarsPlus": "2+ Stars",
"threeStarsPlus": "3+ Stars", "threeStarsPlus": "3+ Stars",
"fourStarsPlus": "4+ Stars", "fourStarsPlus": "4+ Stars",
"fiveStarsOnly": "5 Stars Only" "fiveStarsOnly": "5 Stars Only",
"colorLabels": "Color labels",
"showOnlyColor": "Show only {{color}}",
"myColorLabels": "Your marks",
"showOnlyMyColor": "Show only my {{color}} marks"
}, },
"setup": { "setup": {
"title": "Welcome to PicPeak", "title": "Welcome to PicPeak",
@@ -4102,7 +4148,10 @@
"copied": "Copied to clipboard.", "copied": "Copied to clipboard.",
"copyFailed": "Clipboard write blocked. Select the text and copy manually.", "copyFailed": "Clipboard write blocked. Select the text and copy manually.",
"download": "Download as file" "download": "Download as file"
} },
"markSource": "XMP stars & colour from",
"markSourceClient": "Client selections",
"markSourceMine": "Your marks"
}, },
"photoSort": { "photoSort": {
"defaultSort": "Default Photo Sort", "defaultSort": "Default Photo Sort",
+62 -7
View File
@@ -318,7 +318,9 @@
"anonymous": "Anónimo" "anonymous": "Anónimo"
}, },
"rated": "Valorado", "rated": "Valorado",
"commented": "Comentado" "commented": "Comentado",
"colorFilter": "Color",
"filterByColor": "Mostrar solo {{color}}"
}, },
"categories": { "categories": {
"title": "Categorías de fotos", "title": "Categorías de fotos",
@@ -923,7 +925,17 @@
"expirationWarning": "Las galerías sin expiración permanecerán activas hasta ser archivadas manualmente", "expirationWarning": "Las galerías sin expiración permanecerán activas hasta ser archivadas manualmente",
"saveSettings": "Guardar ajustes de eventos", "saveSettings": "Guardar ajustes de eventos",
"noteTitle": "Nota", "noteTitle": "Nota",
"noteText": "Estos ajustes solo afectan a la creación de nuevos eventos. Los eventos existentes no se ven afectados. El comportamiento por defecto requiere todos los campos." "noteText": "Estos ajustes solo afectan a la creación de nuevos eventos. Los eventos existentes no se ven afectados. El comportamiento por defecto requiere todos los campos.",
"feedbackTypeDefaultsHelp": "Qué tipos de valoración traen las galerías nuevas. Las galerías existentes no cambian: cada una se puede ajustar por separado.",
"defaultAllowRatings": "Valoración con estrellas",
"defaultAllowLikes": "Me gusta",
"defaultAllowFavorites": "Favoritos",
"defaultAllowComments": "Comentarios",
"defaultAllowReactions": "Reacciones emoji",
"defaultAllowColorLabels": "Etiquetas de color",
"defaultKeybindMode": "Atajos predeterminados del visor",
"keybindColors": "Solo colores — 1 verde, 2 amarillo, 3 rojo",
"keybindLightroom": "Lightroom — 1-5 estrellas, 6-9 colores"
}, },
"imageSecurity": { "imageSecurity": {
"title": "Proteger Imagen", "title": "Proteger Imagen",
@@ -1569,7 +1581,23 @@
"hideSelected": "Ocultar", "hideSelected": "Ocultar",
"showSelected": "Mostrar", "showSelected": "Mostrar",
"hiddenSuccess": "Fotos ocultas a los invitados", "hiddenSuccess": "Fotos ocultas a los invitados",
"visibleSuccess": "Fotos ahora visibles para invitados" "visibleSuccess": "Fotos ahora visibles para invitados",
"myMarks": "Tus marcas",
"myMarksHelp": "Solo tú las ves. Nunca aparecen en la galería del cliente y se exportan a Lightroom como XMP.",
"myRating": "Tu valoración",
"clearRating": "Quitar tu valoración",
"rateStars": "Valorar con {{count}} estrellas",
"markError": "No se pudo guardar tu marca",
"yourMarkColor": "Tu marca: {{color}}",
"yourMarkRating": "Tu valoración: {{count}}"
},
"guests": {
"columns": {
"colorLabels": "Etiquetas de color"
},
"detail": {
"labeled": "Etiquetas de color"
}
} }
}, },
"permissions": { "permissions": {
@@ -2418,8 +2446,28 @@
"identityModeSimple": "Feedback simple", "identityModeSimple": "Feedback simple",
"identityModeSimpleDesc": "Anónimo, basado en dispositivo. Visitantes del mismo dispositivo comparten estado.", "identityModeSimpleDesc": "Anónimo, basado en dispositivo. Visitantes del mismo dispositivo comparten estado.",
"identityModeGuest": "Por invitado", "identityModeGuest": "Por invitado",
"identityModeGuestDesc": "Cada visitante introduce su nombre. Habilita rastreo por invitado e insights admin." "identityModeGuestDesc": "Cada visitante introduce su nombre. Habilita rastreo por invitado e insights admin.",
} "colorLabels": "Etiquetas de color",
"colorLabelsDesc": "Un color por invitado y foto, con el conjunto de colores de Lightroom para que la selección se traslade mediante XMP",
"keybindMode": "Atajos de teclado",
"keybindColors": "Solo colores (lo más sencillo)",
"keybindColorsDesc": "1 = verde (1.ª opción), 2 = amarillo (2.ª opción), 3 = rojo (descartada)",
"keybindLightroom": "Valores por defecto de Lightroom",
"keybindLightroomDesc": "1-5 asignan estrellas, 6-9 asignan rojo / amarillo / verde / azul"
},
"colorLabels": {
"red": "Rojo",
"yellow": "Amarillo",
"green": "Verde",
"blue": "Azul",
"purple": "Morado"
},
"colorLabel": "etiqueta de color",
"colorLabelsTitle": "Etiquetas de color",
"colorLabelError": "No se pudo actualizar la etiqueta de color",
"removeColorLabel": "Quitar la etiqueta {{color}}",
"setColorLabel": "Marcar como {{color}}",
"markedAs": "Marcado como {{color}}"
}, },
"filter": { "filter": {
"feedbackFilters": "Filtros de feedback", "feedbackFilters": "Filtros de feedback",
@@ -2436,7 +2484,11 @@
"hasFavorites": "Con favoritos", "hasFavorites": "Con favoritos",
"hasComments": "Con comentarios", "hasComments": "Con comentarios",
"showingPhotos": "Fotos totales", "showingPhotos": "Fotos totales",
"withRatings": "Con valoraciones" "withRatings": "Con valoraciones",
"colorLabels": "Etiquetas de color",
"showOnlyColor": "Mostrar solo {{color}}",
"myColorLabels": "Tus marcas",
"showOnlyMyColor": "Mostrar solo mis marcas en {{color}}"
}, },
"adminLogin": { "adminLogin": {
"title": "Acceso admin", "title": "Acceso admin",
@@ -2492,7 +2544,10 @@
"error": "Error en la exportación: ", "error": "Error en la exportación: ",
"exportSelected": "Exportar {{count}} seleccionadas", "exportSelected": "Exportar {{count}} seleccionadas",
"exportFiltered": "Exportar fotos filtradas", "exportFiltered": "Exportar fotos filtradas",
"hint": "Selecciona fotos o aplica filtros para exportar" "hint": "Selecciona fotos o aplica filtros para exportar",
"markSource": "Estrellas y color XMP desde",
"markSourceClient": "Selecciones del cliente",
"markSourceMine": "Tus marcas"
}, },
"photoSort": { "photoSort": {
"defaultSort": "Orden por defecto", "defaultSort": "Orden por defecto",
+58 -9
View File
@@ -338,7 +338,9 @@
"photosSelected_one": "{{count}} photo sélectionnée", "photosSelected_one": "{{count}} photo sélectionnée",
"photosSelected_other": "{{count}} photos sélectionnées", "photosSelected_other": "{{count}} photos sélectionnées",
"downloadSelected_one": "Télécharger {{count}} photo", "downloadSelected_one": "Télécharger {{count}} photo",
"downloadSelected_other": "Télécharger {{count}} photos" "downloadSelected_other": "Télécharger {{count}} photos",
"colorFilter": "Couleur",
"filterByColor": "Afficher uniquement {{color}}"
}, },
"categories": { "categories": {
"title": "Catégories de photos", "title": "Catégories de photos",
@@ -892,7 +894,17 @@
"showGalleryFilterBar": "Afficher la barre de filtre dans les galeries", "showGalleryFilterBar": "Afficher la barre de filtre dans les galeries",
"showGalleryFilterBarHelp": "Afficher la recherche par nom de fichier et les contrôles de tri au-dessus des galeries en disposition grille. Désactivez pour une disposition plus épurée.", "showGalleryFilterBarHelp": "Afficher la recherche par nom de fichier et les contrôles de tri au-dessus des galeries en disposition grille. Désactivez pour une disposition plus épurée.",
"enablePhoneField": "Activer le champ de numéro de téléphone", "enablePhoneField": "Activer le champ de numéro de téléphone",
"enablePhoneFieldHelp": "Ajoute une entrée de numéro de téléphone optionnelle dans le formulaire d'événement. Utile pour les automatisations en aval comme la livraison WhatsApp via n8n. Toujours optionnel même lorsqu'il est activé." "enablePhoneFieldHelp": "Ajoute une entrée de numéro de téléphone optionnelle dans le formulaire d'événement. Utile pour les automatisations en aval comme la livraison WhatsApp via n8n. Toujours optionnel même lorsqu'il est activé.",
"feedbackTypeDefaultsHelp": "Types de retours activés par défaut pour les nouvelles galeries. Les galeries existantes ne sont pas modifiées — chaque galerie reste réglable individuellement.",
"defaultAllowRatings": "Notes en étoiles",
"defaultAllowLikes": "J'aime",
"defaultAllowFavorites": "Favoris",
"defaultAllowComments": "Commentaires",
"defaultAllowReactions": "Réactions emoji",
"defaultAllowColorLabels": "Étiquettes de couleur",
"defaultKeybindMode": "Raccourcis par défaut de la visionneuse",
"keybindColors": "Couleurs uniquement — 1 vert, 2 jaune, 3 rouge",
"keybindLightroom": "Lightroom — 1-5 étoiles, 6-9 couleurs"
}, },
"imageSecurity": { "imageSecurity": {
"title": "Protection des images", "title": "Protection des images",
@@ -1659,7 +1671,15 @@
"visibleSuccess": "Photos maintenant visibles pour les invités", "visibleSuccess": "Photos maintenant visibles pour les invités",
"processingStatus": "Traitement en cours…", "processingStatus": "Traitement en cours…",
"processingFailed": "Échec du traitement", "processingFailed": "Échec du traitement",
"retryQueued": "Nouvelle tentative en file d'attente" "retryQueued": "Nouvelle tentative en file d'attente",
"myMarks": "Vos marques",
"myMarksHelp": "Vous seul les voyez. Elles n'apparaissent jamais dans la galerie client et s'exportent vers Lightroom en XMP.",
"myRating": "Votre note",
"clearRating": "Effacer votre note",
"rateStars": "Noter {{count}} étoiles",
"markError": "Impossible d'enregistrer votre marque",
"yourMarkColor": "Votre marque : {{color}}",
"yourMarkRating": "Votre note : {{count}}"
}, },
"events": { "events": {
"tabs": { "tabs": {
@@ -1715,7 +1735,8 @@
"comments": "Commentaires", "comments": "Commentaires",
"ratings": "Notes", "ratings": "Notes",
"reactions": "Réactions", "reactions": "Réactions",
"lastSeen": "Dernière visite" "lastSeen": "Dernière visite",
"colorLabels": "Étiquettes de couleur"
}, },
"view": "Voir les détails", "view": "Voir les détails",
"export": "Exporter", "export": "Exporter",
@@ -1729,7 +1750,8 @@
"favorited": "Favoris", "favorited": "Favoris",
"rated": "Notés", "rated": "Notés",
"reacted": "A réagi", "reacted": "A réagi",
"commented": "Commentés" "commented": "Commentés",
"labeled": "Étiquettes de couleur"
}, },
"inviteStatus": { "inviteStatus": {
"pending": "En attente", "pending": "En attente",
@@ -2567,7 +2589,14 @@
"enableRateLimiting": "Activer la limitation de débit", "enableRateLimiting": "Activer la limitation de débit",
"rateLimitingDesc": "Empêcher le spam en limitant la fréquence des commentaires", "rateLimitingDesc": "Empêcher le spam en limitant la fréquence des commentaires",
"timeWindow": "Fenêtre de temps (minutes)", "timeWindow": "Fenêtre de temps (minutes)",
"maxRequests": "Nombre maximal de requêtes" "maxRequests": "Nombre maximal de requêtes",
"colorLabels": "Étiquettes de couleur",
"colorLabelsDesc": "Une couleur par invité et par photo, avec le jeu de couleurs de Lightroom pour que la sélection soit reprise via XMP",
"keybindMode": "Raccourcis clavier",
"keybindColors": "Couleurs uniquement (le plus simple)",
"keybindColorsDesc": "1 = vert (1er choix), 2 = jaune (2e choix), 3 = rouge (rejeté)",
"keybindLightroom": "Valeurs par défaut de Lightroom",
"keybindLightroomDesc": "1-5 attribuent les étoiles, 6-9 les couleurs rouge / jaune / vert / bleu"
}, },
"settingsUpdated": "Paramètres de commentaires mis à jour", "settingsUpdated": "Paramètres de commentaires mis à jour",
"settingsUpdateError": "Échec de la mise à jour des paramètres", "settingsUpdateError": "Échec de la mise à jour des paramètres",
@@ -2642,7 +2671,20 @@
"onPhoto": "Sur la photo", "onPhoto": "Sur la photo",
"showAll_one": "Afficher tous les {{count}} commentaire en attente", "showAll_one": "Afficher tous les {{count}} commentaire en attente",
"showAll_other": "Afficher tous les {{count}} commentaires en attente", "showAll_other": "Afficher tous les {{count}} commentaires en attente",
"viewAllFeedback": "Voir tous les commentaires et paramètres" "viewAllFeedback": "Voir tous les commentaires et paramètres",
"colorLabels": {
"red": "Rouge",
"yellow": "Jaune",
"green": "Vert",
"blue": "Bleu",
"purple": "Violet"
},
"colorLabel": "étiquette de couleur",
"colorLabelsTitle": "Étiquettes de couleur",
"colorLabelError": "Échec de la mise à jour de l'étiquette de couleur",
"removeColorLabel": "Retirer l'étiquette {{color}}",
"setColorLabel": "Marquer comme {{color}}",
"markedAs": "Marqué comme {{color}}"
}, },
"filter": { "filter": {
"feedbackFilters": "Filtres de commentaires", "feedbackFilters": "Filtres de commentaires",
@@ -2660,7 +2702,11 @@
"twoStarsPlus": "2+ étoiles", "twoStarsPlus": "2+ étoiles",
"threeStarsPlus": "3+ étoiles", "threeStarsPlus": "3+ étoiles",
"fourStarsPlus": "4+ étoiles", "fourStarsPlus": "4+ étoiles",
"fiveStarsOnly": "5 étoiles uniquement" "fiveStarsOnly": "5 étoiles uniquement",
"colorLabels": "Étiquettes de couleur",
"showOnlyColor": "Afficher uniquement {{color}}",
"myColorLabels": "Vos marques",
"showOnlyMyColor": "Afficher uniquement mes marques {{color}}"
}, },
"adminLogin": { "adminLogin": {
"title": "Connexion administrateur", "title": "Connexion administrateur",
@@ -2716,7 +2762,10 @@
"exportFiltered": "Exporter les photos filtrées", "exportFiltered": "Exporter les photos filtrées",
"hint": "Sélectionnez des photos ou appliquez des filtres pour exporter", "hint": "Sélectionnez des photos ou appliquez des filtres pour exporter",
"exportSelected_one": "Exporter {{count}} sélectionné", "exportSelected_one": "Exporter {{count}} sélectionné",
"exportSelected_other": "Exporter {{count}} sélectionnés" "exportSelected_other": "Exporter {{count}} sélectionnés",
"markSource": "Étoiles et couleur XMP depuis",
"markSourceClient": "Sélections du client",
"markSourceMine": "Vos marques"
}, },
"photoSort": { "photoSort": {
"defaultSort": "Tri par défaut des photos", "defaultSort": "Tri par défaut des photos",
+58 -9
View File
@@ -338,7 +338,9 @@
"socials": "Sociale media" "socials": "Sociale media"
}, },
"photosCount_one": "{{count}} foto", "photosCount_one": "{{count}} foto",
"photosCount_other": "{{count}} foto's" "photosCount_other": "{{count}} foto's",
"colorFilter": "Kleur",
"filterByColor": "Alleen {{color}} tonen"
}, },
"categories": { "categories": {
"title": "Fotocategorieen", "title": "Fotocategorieen",
@@ -888,7 +890,17 @@
"showGalleryFilterBar": "Filterbalk in galerijen tonen", "showGalleryFilterBar": "Filterbalk in galerijen tonen",
"showGalleryFilterBarHelp": "Toont de zoek-op-bestandsnaam en sorteerbediening boven rasterindelingsgalerijen. Uitschakelen voor een cleaner lay-out.", "showGalleryFilterBarHelp": "Toont de zoek-op-bestandsnaam en sorteerbediening boven rasterindelingsgalerijen. Uitschakelen voor een cleaner lay-out.",
"enablePhoneField": "Telefoonnummerveld inschakelen", "enablePhoneField": "Telefoonnummerveld inschakelen",
"enablePhoneFieldHelp": "Voegt een optioneel telefoonnumerinvoerveld toe aan het evenementformulier. Handig voor automatisering zoals WhatsApp-levering via n8n. Altijd optioneel, ook als ingeschakeld." "enablePhoneFieldHelp": "Voegt een optioneel telefoonnumerinvoerveld toe aan het evenementformulier. Handig voor automatisering zoals WhatsApp-levering via n8n. Altijd optioneel, ook als ingeschakeld.",
"feedbackTypeDefaultsHelp": "Met welke feedbacktypen nieuwe galerijen starten. Bestaande galerijen veranderen niet — elke galerij blijft afzonderlijk instelbaar.",
"defaultAllowRatings": "Sterbeoordelingen",
"defaultAllowLikes": "Likes",
"defaultAllowFavorites": "Favorieten",
"defaultAllowComments": "Reacties",
"defaultAllowReactions": "Emoji-reacties",
"defaultAllowColorLabels": "Kleurlabels",
"defaultKeybindMode": "Standaard sneltoetsen in de weergave",
"keybindColors": "Alleen kleuren — 1 groen, 2 geel, 3 rood",
"keybindLightroom": "Lightroom — 1-5 sterren, 6-9 kleuren"
}, },
"imageSecurity": { "imageSecurity": {
"title": "Afbeeldingsbeveiliging", "title": "Afbeeldingsbeveiliging",
@@ -1648,7 +1660,15 @@
"visibleSuccess": "Foto's nu zichtbaar voor gasten", "visibleSuccess": "Foto's nu zichtbaar voor gasten",
"processingStatus": "Verwerken…", "processingStatus": "Verwerken…",
"processingFailed": "Mislukt", "processingFailed": "Mislukt",
"retryQueued": "Nieuwe poging in wachtrij" "retryQueued": "Nieuwe poging in wachtrij",
"myMarks": "Jouw markeringen",
"myMarksHelp": "Alleen jij ziet deze. Ze verschijnen nooit in de klantgalerij en gaan als XMP naar Lightroom.",
"myRating": "Jouw beoordeling",
"clearRating": "Jouw beoordeling wissen",
"rateStars": "{{count}} sterren geven",
"markError": "Markering opslaan mislukt",
"yourMarkColor": "Jouw markering: {{color}}",
"yourMarkRating": "Jouw beoordeling: {{count}}"
}, },
"events": { "events": {
"tabs": { "tabs": {
@@ -1704,7 +1724,8 @@
"comments": "Opmerkingen", "comments": "Opmerkingen",
"ratings": "Beoordelingen", "ratings": "Beoordelingen",
"reactions": "Reacties", "reactions": "Reacties",
"lastSeen": "Laatste bezoek" "lastSeen": "Laatste bezoek",
"colorLabels": "Kleurlabels"
}, },
"view": "Details bekijken", "view": "Details bekijken",
"export": "Exporteren", "export": "Exporteren",
@@ -1718,7 +1739,8 @@
"favorited": "Favoriet", "favorited": "Favoriet",
"rated": "Beoordeeld", "rated": "Beoordeeld",
"reacted": "Gereageerd", "reacted": "Gereageerd",
"commented": "Becommentarieerd" "commented": "Becommentarieerd",
"labeled": "Kleurlabels"
}, },
"inviteStatus": { "inviteStatus": {
"pending": "In behandeling", "pending": "In behandeling",
@@ -2556,7 +2578,14 @@
"enableRateLimiting": "Snelheidsbeperking inschakelen", "enableRateLimiting": "Snelheidsbeperking inschakelen",
"rateLimitingDesc": "Voorkom spam door de feedbackfrequentie te beperken", "rateLimitingDesc": "Voorkom spam door de feedbackfrequentie te beperken",
"timeWindow": "Tijdvenster (minuten)", "timeWindow": "Tijdvenster (minuten)",
"maxRequests": "Max. verzoeken" "maxRequests": "Max. verzoeken",
"colorLabels": "Kleurlabels",
"colorLabelsDesc": "Eén kleur per gast per foto, met de kleurenset van Lightroom zodat de selectie via XMP meegaat",
"keybindMode": "Sneltoetsen",
"keybindColors": "Alleen kleuren (eenvoudigst)",
"keybindColorsDesc": "1 = groen (1e keuze), 2 = geel (2e keuze), 3 = rood (afgewezen)",
"keybindLightroom": "Lightroom-standaard",
"keybindLightroomDesc": "1-5 geven sterren, 6-9 geven rood / geel / groen / blauw"
}, },
"settingsUpdated": "Feedbackinstellingen bijgewerkt", "settingsUpdated": "Feedbackinstellingen bijgewerkt",
"settingsUpdateError": "Bijwerken instellingen mislukt", "settingsUpdateError": "Bijwerken instellingen mislukt",
@@ -2631,7 +2660,20 @@
"onPhoto": "Op foto", "onPhoto": "Op foto",
"showAll_one": "Alle {{count}} openstaande opmerking tonen", "showAll_one": "Alle {{count}} openstaande opmerking tonen",
"showAll_other": "Alle {{count}} openstaande opmerkingen tonen", "showAll_other": "Alle {{count}} openstaande opmerkingen tonen",
"viewAllFeedback": "Alle feedback en instellingen bekijken" "viewAllFeedback": "Alle feedback en instellingen bekijken",
"colorLabels": {
"red": "Rood",
"yellow": "Geel",
"green": "Groen",
"blue": "Blauw",
"purple": "Paars"
},
"colorLabel": "kleurlabel",
"colorLabelsTitle": "Kleurlabels",
"colorLabelError": "Bijwerken van kleurlabel mislukt",
"removeColorLabel": "Label {{color}} verwijderen",
"setColorLabel": "Markeren als {{color}}",
"markedAs": "Gemarkeerd als {{color}}"
}, },
"filter": { "filter": {
"feedbackFilters": "Feedbackfilters", "feedbackFilters": "Feedbackfilters",
@@ -2649,7 +2691,11 @@
"twoStarsPlus": "2+ sterren", "twoStarsPlus": "2+ sterren",
"threeStarsPlus": "3+ sterren", "threeStarsPlus": "3+ sterren",
"fourStarsPlus": "4+ sterren", "fourStarsPlus": "4+ sterren",
"fiveStarsOnly": "Alleen 5 sterren" "fiveStarsOnly": "Alleen 5 sterren",
"colorLabels": "Kleurlabels",
"showOnlyColor": "Alleen {{color}} tonen",
"myColorLabels": "Jouw markeringen",
"showOnlyMyColor": "Alleen mijn {{color}} markeringen tonen"
}, },
"export": { "export": {
"button": "Exporteren", "button": "Exporteren",
@@ -2658,7 +2704,10 @@
"exportFiltered": "Gefilterde foto's exporteren", "exportFiltered": "Gefilterde foto's exporteren",
"hint": "Selecteer foto's of pas filters toe om te exporteren", "hint": "Selecteer foto's of pas filters toe om te exporteren",
"exportSelected_one": "{{count}} geselecteerde exporteren", "exportSelected_one": "{{count}} geselecteerde exporteren",
"exportSelected_other": "{{count}} geselecteerde exporteren" "exportSelected_other": "{{count}} geselecteerde exporteren",
"markSource": "XMP-sterren & -kleur van",
"markSourceClient": "Klantselectie",
"markSourceMine": "Jouw markeringen"
}, },
"adminLogin": { "adminLogin": {
"title": "Beheerder login", "title": "Beheerder login",
+58 -9
View File
@@ -346,7 +346,9 @@
}, },
"photosCount_many": "{{count}} fotos", "photosCount_many": "{{count}} fotos",
"photosCount_one": "{{count}} foto", "photosCount_one": "{{count}} foto",
"photosCount_other": "{{count}} fotos" "photosCount_other": "{{count}} fotos",
"colorFilter": "Cor",
"filterByColor": "Mostrar apenas {{color}}"
}, },
"categories": { "categories": {
"title": "Categorias de Fotos", "title": "Categorias de Fotos",
@@ -905,7 +907,17 @@
"showGalleryFilterBar": "Mostrar barra de filtros nas galerias", "showGalleryFilterBar": "Mostrar barra de filtros nas galerias",
"showGalleryFilterBarHelp": "Exibe a pesquisa por nome de ficheiro e os controlos de ordenação acima das galerias em grelha. Desative para um layout mais limpo.", "showGalleryFilterBarHelp": "Exibe a pesquisa por nome de ficheiro e os controlos de ordenação acima das galerias em grelha. Desative para um layout mais limpo.",
"enablePhoneField": "Ativar campo de número de telefone", "enablePhoneField": "Ativar campo de número de telefone",
"enablePhoneFieldHelp": "Adiciona um campo opcional de número de telefone ao formulário de evento. Útil para automatizações como entrega via WhatsApp com n8n. Sempre opcional mesmo quando ativado." "enablePhoneFieldHelp": "Adiciona um campo opcional de número de telefone ao formulário de evento. Útil para automatizações como entrega via WhatsApp com n8n. Sempre opcional mesmo quando ativado.",
"feedbackTypeDefaultsHelp": "Que tipos de feedback as novas galerias trazem. As galerias existentes não são alteradas — cada galeria continua a poder ser ajustada individualmente.",
"defaultAllowRatings": "Classificação por estrelas",
"defaultAllowLikes": "Gostos",
"defaultAllowFavorites": "Favoritos",
"defaultAllowComments": "Comentários",
"defaultAllowReactions": "Reações emoji",
"defaultAllowColorLabels": "Etiquetas de cor",
"defaultKeybindMode": "Atalhos predefinidos do visualizador",
"keybindColors": "Apenas cores — 1 verde, 2 amarelo, 3 vermelho",
"keybindLightroom": "Lightroom — 1-5 estrelas, 6-9 cores"
}, },
"imageSecurity": { "imageSecurity": {
"title": "Proteção de Imagem", "title": "Proteção de Imagem",
@@ -1669,7 +1681,15 @@
"visibleSuccess": "Fotos agora visíveis aos visitantes", "visibleSuccess": "Fotos agora visíveis aos visitantes",
"processingStatus": "A processar…", "processingStatus": "A processar…",
"processingFailed": "Falhado", "processingFailed": "Falhado",
"retryQueued": "Nova tentativa em fila" "retryQueued": "Nova tentativa em fila",
"myMarks": "As suas marcas",
"myMarksHelp": "Só você as vê. Nunca aparecem na galeria do cliente e são exportadas para o Lightroom como XMP.",
"myRating": "A sua classificação",
"clearRating": "Remover a sua classificação",
"rateStars": "Classificar com {{count}} estrelas",
"markError": "Não foi possível guardar a sua marca",
"yourMarkColor": "A sua marca: {{color}}",
"yourMarkRating": "A sua classificação: {{count}}"
}, },
"events": { "events": {
"tabs": { "tabs": {
@@ -1729,7 +1749,8 @@
"comments": "Comentários", "comments": "Comentários",
"ratings": "Avaliações", "ratings": "Avaliações",
"reactions": "Reações", "reactions": "Reações",
"lastSeen": "Última visita" "lastSeen": "Última visita",
"colorLabels": "Etiquetas de cor"
}, },
"view": "Ver detalhes", "view": "Ver detalhes",
"export": "Exportar", "export": "Exportar",
@@ -1743,7 +1764,8 @@
"favorited": "Favorito", "favorited": "Favorito",
"rated": "Avaliado", "rated": "Avaliado",
"reacted": "Reagiu", "reacted": "Reagiu",
"commented": "Comentado" "commented": "Comentado",
"labeled": "Etiquetas de cor"
}, },
"inviteStatus": { "inviteStatus": {
"pending": "Pendente", "pending": "Pendente",
@@ -2581,7 +2603,14 @@
"enableRateLimiting": "Ativar limitação de taxa", "enableRateLimiting": "Ativar limitação de taxa",
"rateLimitingDesc": "Previne spam limitando a frequência de feedback", "rateLimitingDesc": "Previne spam limitando a frequência de feedback",
"timeWindow": "Janela de tempo (minutos)", "timeWindow": "Janela de tempo (minutos)",
"maxRequests": "Pedidos máx." "maxRequests": "Pedidos máx.",
"colorLabels": "Etiquetas de cor",
"colorLabelsDesc": "Uma cor por convidado e por foto, com o conjunto de cores do Lightroom para que a seleção seja transferida via XMP",
"keybindMode": "Atalhos de teclado",
"keybindColors": "Apenas cores (mais simples)",
"keybindColorsDesc": "1 = verde (1.ª escolha), 2 = amarelo (2.ª escolha), 3 = vermelho (rejeitada)",
"keybindLightroom": "Predefinições do Lightroom",
"keybindLightroomDesc": "1-5 atribuem estrelas, 6-9 atribuem vermelho / amarelo / verde / azul"
}, },
"settingsUpdated": "Definições de feedback atualizadas", "settingsUpdated": "Definições de feedback atualizadas",
"settingsUpdateError": "Falha ao atualizar definições", "settingsUpdateError": "Falha ao atualizar definições",
@@ -2661,7 +2690,20 @@
"showAll_many": "Mostrar todos os {{count}} comentários pendentes", "showAll_many": "Mostrar todos os {{count}} comentários pendentes",
"showAll_one": "Mostrar o {{count}} comentário pendente", "showAll_one": "Mostrar o {{count}} comentário pendente",
"showAll_other": "Mostrar todos os {{count}} comentários pendentes", "showAll_other": "Mostrar todos os {{count}} comentários pendentes",
"viewAllFeedback": "Ver todo o feedback e definições" "viewAllFeedback": "Ver todo o feedback e definições",
"colorLabels": {
"red": "Vermelho",
"yellow": "Amarelo",
"green": "Verde",
"blue": "Azul",
"purple": "Roxo"
},
"colorLabel": "etiqueta de cor",
"colorLabelsTitle": "Etiquetas de cor",
"colorLabelError": "Não foi possível atualizar a etiqueta de cor",
"removeColorLabel": "Remover a etiqueta {{color}}",
"setColorLabel": "Marcar como {{color}}",
"markedAs": "Marcado como {{color}}"
}, },
"filter": { "filter": {
"feedbackFilters": "Filtros de Feedback", "feedbackFilters": "Filtros de Feedback",
@@ -2679,7 +2721,11 @@
"twoStarsPlus": "2+ estrelas", "twoStarsPlus": "2+ estrelas",
"threeStarsPlus": "3+ estrelas", "threeStarsPlus": "3+ estrelas",
"fourStarsPlus": "4+ estrelas", "fourStarsPlus": "4+ estrelas",
"fiveStarsOnly": "Apenas 5 estrelas" "fiveStarsOnly": "Apenas 5 estrelas",
"colorLabels": "Etiquetas de cor",
"showOnlyColor": "Mostrar apenas {{color}}",
"myColorLabels": "As suas marcas",
"showOnlyMyColor": "Mostrar apenas as minhas marcas {{color}}"
}, },
"adminLogin": { "adminLogin": {
"title": "Login Administrativo", "title": "Login Administrativo",
@@ -2736,7 +2782,10 @@
"hint": "Selecione fotos ou aplique filtros para exportar", "hint": "Selecione fotos ou aplique filtros para exportar",
"exportSelected_many": "Exportar {{count}} selecionados", "exportSelected_many": "Exportar {{count}} selecionados",
"exportSelected_one": "Exportar {{count}} selecionado", "exportSelected_one": "Exportar {{count}} selecionado",
"exportSelected_other": "Exportar {{count}} selecionados" "exportSelected_other": "Exportar {{count}} selecionados",
"markSource": "Estrelas e cor XMP a partir de",
"markSourceClient": "Seleções do cliente",
"markSourceMine": "As suas marcas"
}, },
"photoSort": { "photoSort": {
"defaultSort": "Ordenação padrão das fotos", "defaultSort": "Ordenação padrão das fotos",
+58 -9
View File
@@ -354,7 +354,9 @@
"photosCount_few": "{{count}} фото", "photosCount_few": "{{count}} фото",
"photosCount_many": "{{count}} фото", "photosCount_many": "{{count}} фото",
"photosCount_one": "{{count}} фото", "photosCount_one": "{{count}} фото",
"photosCount_other": "{{count}} фото" "photosCount_other": "{{count}} фото",
"colorFilter": "Цвет",
"filterByColor": "Показать только «{{color}}»"
}, },
"categories": { "categories": {
"title": "Категории фото", "title": "Категории фото",
@@ -911,7 +913,17 @@
"showGalleryFilterBar": "Показывать панель фильтров в галереях", "showGalleryFilterBar": "Показывать панель фильтров в галереях",
"showGalleryFilterBarHelp": "Отображает поиск по имени файла и элементы управления сортировкой над галереями с сеткой. Отключите для более чистого интерфейса.", "showGalleryFilterBarHelp": "Отображает поиск по имени файла и элементы управления сортировкой над галереями с сеткой. Отключите для более чистого интерфейса.",
"enablePhoneField": "Включить поле номера телефона", "enablePhoneField": "Включить поле номера телефона",
"enablePhoneFieldHelp": "Добавляет необязательное поле ввода номера телефона в форму события. Полезно для автоматизаций, таких как доставка в WhatsApp через n8n. Всегда необязательно, даже если включено." "enablePhoneFieldHelp": "Добавляет необязательное поле ввода номера телефона в форму события. Полезно для автоматизаций, таких как доставка в WhatsApp через n8n. Всегда необязательно, даже если включено.",
"feedbackTypeDefaultsHelp": "С какими типами отзывов создаются новые галереи. Существующие галереи не меняются — каждую можно настроить отдельно.",
"defaultAllowRatings": "Оценка звёздами",
"defaultAllowLikes": "Лайки",
"defaultAllowFavorites": "Избранное",
"defaultAllowComments": "Комментарии",
"defaultAllowReactions": "Эмодзи-реакции",
"defaultAllowColorLabels": "Цветовые метки",
"defaultKeybindMode": "Горячие клавиши просмотра по умолчанию",
"keybindColors": "Только цвета — 1 зелёный, 2 жёлтый, 3 красный",
"keybindLightroom": "Lightroom — 15 звёзды, 6–9 цвета"
}, },
"imageSecurity": { "imageSecurity": {
"title": "Защита изображений", "title": "Защита изображений",
@@ -1690,7 +1702,15 @@
"visibleSuccess": "Фотографии теперь видны гостям", "visibleSuccess": "Фотографии теперь видны гостям",
"processingStatus": "Обработка…", "processingStatus": "Обработка…",
"processingFailed": "Ошибка", "processingFailed": "Ошибка",
"retryQueued": "Повтор в очереди" "retryQueued": "Повтор в очереди",
"myMarks": "Ваши отметки",
"myMarksHelp": "Видны только вам. Они никогда не появляются в клиентской галерее и экспортируются в Lightroom через XMP.",
"myRating": "Ваша оценка",
"clearRating": "Убрать вашу оценку",
"rateStars": "Оценить на {{count}}",
"markError": "Не удалось сохранить отметку",
"yourMarkColor": "Ваша отметка: {{color}}",
"yourMarkRating": "Ваша оценка: {{count}}"
}, },
"events": { "events": {
"tabs": { "tabs": {
@@ -1754,7 +1774,8 @@
"comments": "Комментарии", "comments": "Комментарии",
"ratings": "Оценки", "ratings": "Оценки",
"reactions": "Реакции", "reactions": "Реакции",
"lastSeen": "Последний визит" "lastSeen": "Последний визит",
"colorLabels": "Цветовые метки"
}, },
"view": "Подробнее", "view": "Подробнее",
"export": "Экспортировать", "export": "Экспортировать",
@@ -1768,7 +1789,8 @@
"favorited": "В избранном", "favorited": "В избранном",
"rated": "Оценено", "rated": "Оценено",
"reacted": "Отреагировал", "reacted": "Отреагировал",
"commented": "Прокомментировано" "commented": "Прокомментировано",
"labeled": "Цветовые метки"
}, },
"inviteStatus": { "inviteStatus": {
"pending": "Ожидает", "pending": "Ожидает",
@@ -2606,7 +2628,14 @@
"enableRateLimiting": "Включить ограничение частоты", "enableRateLimiting": "Включить ограничение частоты",
"rateLimitingDesc": "Предотвращает спам, ограничивая частоту отзывов", "rateLimitingDesc": "Предотвращает спам, ограничивая частоту отзывов",
"timeWindow": "Временное окно (минуты)", "timeWindow": "Временное окно (минуты)",
"maxRequests": "Макс. запросов" "maxRequests": "Макс. запросов",
"colorLabels": "Цветовые метки",
"colorLabelsDesc": "Одна метка на гостя и фотографию, в цветовой схеме Lightroom — выбор переносится через XMP",
"keybindMode": "Горячие клавиши",
"keybindColors": "Только цвета (самый простой вариант)",
"keybindColorsDesc": "1 — зелёный (1-й выбор), 2 — жёлтый (2-й выбор), 3 — красный (отклонено)",
"keybindLightroom": "Как в Lightroom",
"keybindLightroomDesc": "1–5 ставят звёзды, 6–9 — красный / жёлтый / зелёный / синий"
}, },
"settingsUpdated": "Настройки отзывов обновлены", "settingsUpdated": "Настройки отзывов обновлены",
"settingsUpdateError": "Не удалось обновить настройки", "settingsUpdateError": "Не удалось обновить настройки",
@@ -2691,7 +2720,20 @@
"showAll_many": "Показать все {{count}} ожидающих комментариев", "showAll_many": "Показать все {{count}} ожидающих комментариев",
"showAll_one": "Показать {{count}} ожидающий комментарий", "showAll_one": "Показать {{count}} ожидающий комментарий",
"showAll_other": "Показать все {{count}} ожидающих комментариев", "showAll_other": "Показать все {{count}} ожидающих комментариев",
"viewAllFeedback": "Просмотреть все отзывы и настройки" "viewAllFeedback": "Просмотреть все отзывы и настройки",
"colorLabels": {
"red": "Красный",
"yellow": "Жёлтый",
"green": "Зелёный",
"blue": "Синий",
"purple": "Фиолетовый"
},
"colorLabel": "цветовая метка",
"colorLabelsTitle": "Цветовые метки",
"colorLabelError": "Не удалось обновить цветовую метку",
"removeColorLabel": "Убрать метку «{{color}}»",
"setColorLabel": "Отметить как «{{color}}»",
"markedAs": "Отмечено как «{{color}}»"
}, },
"filter": { "filter": {
"feedbackFilters": "Фильтры отзывов", "feedbackFilters": "Фильтры отзывов",
@@ -2709,7 +2751,11 @@
"twoStarsPlus": "2+ звезды", "twoStarsPlus": "2+ звезды",
"threeStarsPlus": "3+ звезды", "threeStarsPlus": "3+ звезды",
"fourStarsPlus": "4+ звезды", "fourStarsPlus": "4+ звезды",
"fiveStarsOnly": "Только 5 звёзд" "fiveStarsOnly": "Только 5 звёзд",
"colorLabels": "Цветовые метки",
"showOnlyColor": "Показать только «{{color}}»",
"myColorLabels": "Ваши отметки",
"showOnlyMyColor": "Показать только мои отметки «{{color}}»"
}, },
"adminLogin": { "adminLogin": {
"title": "Вход для администратора", "title": "Вход для администратора",
@@ -2767,7 +2813,10 @@
"exportSelected_few": "Экспортировать {{count}} выбранных", "exportSelected_few": "Экспортировать {{count}} выбранных",
"exportSelected_many": "Экспортировать {{count}} выбранных", "exportSelected_many": "Экспортировать {{count}} выбранных",
"exportSelected_one": "Экспортировать {{count}} выбранный", "exportSelected_one": "Экспортировать {{count}} выбранный",
"exportSelected_other": "Экспортировать {{count}} выбранных" "exportSelected_other": "Экспортировать {{count}} выбранных",
"markSource": "Звёзды и цвет для XMP из",
"markSourceClient": "Выбор клиента",
"markSourceMine": "Ваши отметки"
}, },
"photoSort": { "photoSort": {
"defaultSort": "Сортировка фото по умолчанию", "defaultSort": "Сортировка фото по умолчанию",
+58 -9
View File
@@ -338,7 +338,9 @@
"photosSelected_one": "Izbrana {{count}} fotografija", "photosSelected_one": "Izbrana {{count}} fotografija",
"photosSelected_other": "Izbranih {{count}} fotografij", "photosSelected_other": "Izbranih {{count}} fotografij",
"downloadSelected_one": "Prenesi {{count}} fotografijo", "downloadSelected_one": "Prenesi {{count}} fotografijo",
"downloadSelected_other": "Prenesi {{count}} fotografij" "downloadSelected_other": "Prenesi {{count}} fotografij",
"colorFilter": "Barva",
"filterByColor": "Prikaži samo {{color}}"
}, },
"categories": { "categories": {
"title": "Kategorije fotografij", "title": "Kategorije fotografij",
@@ -892,7 +894,17 @@
"showGalleryFilterBar": "Prikaži vrstico filtrov v galerijah", "showGalleryFilterBar": "Prikaži vrstico filtrov v galerijah",
"showGalleryFilterBarHelp": "Prikaže iskanje po imenu datoteke in razvrščanje nad galerijami z mrežno postavitvijo. Izklopite za čistejšo postavitev.", "showGalleryFilterBarHelp": "Prikaže iskanje po imenu datoteke in razvrščanje nad galerijami z mrežno postavitvijo. Izklopite za čistejšo postavitev.",
"enablePhoneField": "Omogoči polje za telefonsko številko", "enablePhoneField": "Omogoči polje za telefonsko številko",
"enablePhoneFieldHelp": "Doda neobvezno polje za telefonsko številko v obrazec dogodka. Uporabno za nadaljnje avtomatizacije, kot je dostava prek WhatsAppa z n8n. Vedno je neobvezno, tudi ko je omogočeno." "enablePhoneFieldHelp": "Doda neobvezno polje za telefonsko številko v obrazec dogodka. Uporabno za nadaljnje avtomatizacije, kot je dostava prek WhatsAppa z n8n. Vedno je neobvezno, tudi ko je omogočeno.",
"feedbackTypeDefaultsHelp": "S katerimi vrstami odzivov se ustvarijo nove galerije. Obstoječe galerije ostanejo nespremenjene vsako galerijo je še vedno mogoče nastaviti posebej.",
"defaultAllowRatings": "Ocene z zvezdicami",
"defaultAllowLikes": "Všečki",
"defaultAllowFavorites": "Priljubljene",
"defaultAllowComments": "Komentarji",
"defaultAllowReactions": "Odzivi z emoji",
"defaultAllowColorLabels": "Barvne oznake",
"defaultKeybindMode": "Privzete bližnjice v pregledovalniku",
"keybindColors": "Samo barve 1 zelena, 2 rumena, 3 rdeča",
"keybindLightroom": "Lightroom 15 zvezdice, 69 barve"
}, },
"imageSecurity": { "imageSecurity": {
"title": "Zaščita slik", "title": "Zaščita slik",
@@ -1648,7 +1660,15 @@
"visibleSuccess": "Fotografije so zdaj vidne gostom", "visibleSuccess": "Fotografije so zdaj vidne gostom",
"processingStatus": "Obdelava…", "processingStatus": "Obdelava…",
"processingFailed": "Neuspešno", "processingFailed": "Neuspešno",
"retryQueued": "Ponovni poskus v čakalni vrsti" "retryQueued": "Ponovni poskus v čakalni vrsti",
"myMarks": "Vaše oznake",
"myMarksHelp": "Vidite jih samo vi. V galeriji stranke se nikoli ne prikažejo, izvozijo pa se v Lightroom kot XMP.",
"myRating": "Vaša ocena",
"clearRating": "Odstrani vašo oceno",
"rateStars": "Oceni z {{count}} zvezdicami",
"markError": "Oznake ni bilo mogoče shraniti",
"yourMarkColor": "Vaša oznaka: {{color}}",
"yourMarkRating": "Vaša ocena: {{count}}"
}, },
"events": { "events": {
"tabs": { "tabs": {
@@ -1704,7 +1724,8 @@
"comments": "Komentarji", "comments": "Komentarji",
"ratings": "Ocene", "ratings": "Ocene",
"reactions": "Odzivi", "reactions": "Odzivi",
"lastSeen": "Nazadnje viden" "lastSeen": "Nazadnje viden",
"colorLabels": "Barvne oznake"
}, },
"view": "Ogled podrobnosti", "view": "Ogled podrobnosti",
"export": "Izvozi", "export": "Izvozi",
@@ -1718,7 +1739,8 @@
"favorited": "Dodano med priljubljene", "favorited": "Dodano med priljubljene",
"rated": "Ocenjeno", "rated": "Ocenjeno",
"reacted": "Odzval se", "reacted": "Odzval se",
"commented": "Komentirano" "commented": "Komentirano",
"labeled": "Barvne oznake"
}, },
"inviteStatus": { "inviteStatus": {
"pending": "Čaka", "pending": "Čaka",
@@ -2556,7 +2578,14 @@
"enableRateLimiting": "Omogoči omejevanje hitrosti", "enableRateLimiting": "Omogoči omejevanje hitrosti",
"rateLimitingDesc": "Preprečite spam z omejitvijo pogostosti povratnih informacij", "rateLimitingDesc": "Preprečite spam z omejitvijo pogostosti povratnih informacij",
"timeWindow": "Časovno okno (minute)", "timeWindow": "Časovno okno (minute)",
"maxRequests": "Največ zahtev" "maxRequests": "Največ zahtev",
"colorLabels": "Barvne oznake",
"colorLabelsDesc": "Ena barva na gosta in fotografijo, v barvnem naboru Lightrooma, da se izbor prenese prek XMP",
"keybindMode": "Bližnjice na tipkovnici",
"keybindColors": "Samo barve (najpreprostejše)",
"keybindColorsDesc": "1 = zelena (1. izbira), 2 = rumena (2. izbira), 3 = rdeča (zavrnjeno)",
"keybindLightroom": "Privzeto kot v Lightroomu",
"keybindLightroomDesc": "15 določijo zvezdice, 69 rdečo / rumeno / zeleno / modro"
}, },
"settingsUpdated": "Nastavitve povratnih informacij posodobljene", "settingsUpdated": "Nastavitve povratnih informacij posodobljene",
"settingsUpdateError": "Nastavitev ni bilo mogoče posodobiti", "settingsUpdateError": "Nastavitev ni bilo mogoče posodobiti",
@@ -2631,7 +2660,20 @@
"onPhoto": "Na fotografiji", "onPhoto": "Na fotografiji",
"showAll_one": "Prikaži vseh {{count}} čakajočih komentarjev", "showAll_one": "Prikaži vseh {{count}} čakajočih komentarjev",
"showAll_other": "Prikaži vseh {{count}} čakajočih komentarjev", "showAll_other": "Prikaži vseh {{count}} čakajočih komentarjev",
"viewAllFeedback": "Ogled vseh povratnih informacij in nastavitev" "viewAllFeedback": "Ogled vseh povratnih informacij in nastavitev",
"colorLabels": {
"red": "Rdeča",
"yellow": "Rumena",
"green": "Zelena",
"blue": "Modra",
"purple": "Vijolična"
},
"colorLabel": "barvna oznaka",
"colorLabelsTitle": "Barvne oznake",
"colorLabelError": "Barvne oznake ni bilo mogoče posodobiti",
"removeColorLabel": "Odstrani oznako {{color}}",
"setColorLabel": "Označi kot {{color}}",
"markedAs": "Označeno kot {{color}}"
}, },
"filter": { "filter": {
"feedbackFilters": "Filtri povratnih informacij", "feedbackFilters": "Filtri povratnih informacij",
@@ -2649,7 +2691,11 @@
"twoStarsPlus": "2+ zvezdici", "twoStarsPlus": "2+ zvezdici",
"threeStarsPlus": "3+ zvezdice", "threeStarsPlus": "3+ zvezdice",
"fourStarsPlus": "4+ zvezdice", "fourStarsPlus": "4+ zvezdice",
"fiveStarsOnly": "Samo 5 zvezdic" "fiveStarsOnly": "Samo 5 zvezdic",
"colorLabels": "Barvne oznake",
"showOnlyColor": "Prikaži samo {{color}}",
"myColorLabels": "Vaše oznake",
"showOnlyMyColor": "Prikaži samo moje oznake {{color}}"
}, },
"adminLogin": { "adminLogin": {
"title": "Prijava administratorja", "title": "Prijava administratorja",
@@ -2705,7 +2751,10 @@
"exportFiltered": "Izvozi filtrirane fotografije", "exportFiltered": "Izvozi filtrirane fotografije",
"hint": "Izberite fotografije ali uporabite filtre za izvoz", "hint": "Izberite fotografije ali uporabite filtre za izvoz",
"exportSelected_one": "Izvozi {{count}} izbrano", "exportSelected_one": "Izvozi {{count}} izbrano",
"exportSelected_other": "Izvozi {{count}} izbranih" "exportSelected_other": "Izvozi {{count}} izbranih",
"markSource": "Zvezdice in barva XMP iz",
"markSourceClient": "Izbor stranke",
"markSourceMine": "Vaše oznake"
}, },
"photoSort": { "photoSort": {
"defaultSort": "Privzeto razvrščanje fotografij", "defaultSort": "Privzeto razvrščanje fotografij",
+20 -6
View File
@@ -65,6 +65,8 @@ interface FormData {
allow_comments: boolean; allow_comments: boolean;
allow_favorites: boolean; allow_favorites: boolean;
allow_reactions: boolean; allow_reactions: boolean;
allow_color_labels: boolean;
keybind_mode: 'colors' | 'lightroom';
require_name_email: boolean; require_name_email: boolean;
moderate_comments: boolean; moderate_comments: boolean;
show_feedback_to_guests: boolean; show_feedback_to_guests: boolean;
@@ -134,6 +136,8 @@ export const CreateEventPage: React.FC = () => {
allow_comments: true, allow_comments: true,
allow_favorites: true, allow_favorites: true,
allow_reactions: true, allow_reactions: true,
allow_color_labels: false,
keybind_mode: 'colors',
require_name_email: false, require_name_email: false,
moderate_comments: true, moderate_comments: true,
show_feedback_to_guests: true, show_feedback_to_guests: true,
@@ -270,11 +274,12 @@ export const CreateEventPage: React.FC = () => {
})); }));
}, [publicSettings]); }, [publicSettings]);
// Honour the global "Enable Guest Feedback by default" admin setting (#520). // Honour the global guest-feedback defaults (#520 for the master toggle,
// Same one-shot apply pattern as require_password above — only seeds the // #1044 for the per-type ones). Same one-shot apply pattern as
// master toggle. The sub-toggles (likes / ratings / comments) keep their // require_password above. This form POSTs every sub-toggle explicitly, so
// hard-coded true defaults so a flipped master immediately gives sensible // seeding them here is what makes the Settings > Events defaults actually
// behaviour without a second admin setting to manage. // reach a gallery created through the UI — the server-side inheritance in
// feedbackDefaults.js only covers callers that omit them (the v1 API).
const feedbackEnabledDefaultApplied = useRef(false); const feedbackEnabledDefaultApplied = useRef(false);
useEffect(() => { useEffect(() => {
if (feedbackEnabledDefaultApplied.current) return; if (feedbackEnabledDefaultApplied.current) return;
@@ -284,7 +289,14 @@ export const CreateEventPage: React.FC = () => {
...prev, ...prev,
feedback_settings: { feedback_settings: {
...prev.feedback_settings, ...prev.feedback_settings,
feedback_enabled: publicSettings.event_default_feedback_enabled === true feedback_enabled: publicSettings.event_default_feedback_enabled === true,
allow_ratings: publicSettings.event_default_allow_ratings !== false,
allow_likes: publicSettings.event_default_allow_likes !== false,
allow_favorites: publicSettings.event_default_allow_favorites !== false,
allow_comments: publicSettings.event_default_allow_comments !== false,
allow_reactions: publicSettings.event_default_allow_reactions !== false,
allow_color_labels: publicSettings.event_default_allow_color_labels === true,
keybind_mode: publicSettings.event_default_keybind_mode === 'lightroom' ? 'lightroom' : 'colors'
} }
})); }));
}, [publicSettings]); }, [publicSettings]);
@@ -485,6 +497,8 @@ export const CreateEventPage: React.FC = () => {
allow_comments: feedbackSettings.allow_comments, allow_comments: feedbackSettings.allow_comments,
allow_favorites: feedbackSettings.allow_favorites, allow_favorites: feedbackSettings.allow_favorites,
allow_reactions: feedbackSettings.allow_reactions, allow_reactions: feedbackSettings.allow_reactions,
allow_color_labels: feedbackSettings.allow_color_labels,
keybind_mode: feedbackSettings.keybind_mode,
require_name_email: feedbackSettings.require_name_email, require_name_email: feedbackSettings.require_name_email,
moderate_comments: feedbackSettings.moderate_comments, moderate_comments: feedbackSettings.moderate_comments,
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests, show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
@@ -46,6 +46,8 @@ export const EventDetailsPage: React.FC = () => {
allow_comments: true, allow_comments: true,
allow_favorites: true, allow_favorites: true,
allow_reactions: true, allow_reactions: true,
allow_color_labels: false,
keybind_mode: 'colors',
require_name_email: false, require_name_email: false,
moderate_comments: true, moderate_comments: true,
show_feedback_to_guests: true, show_feedback_to_guests: true,
@@ -92,6 +94,8 @@ export const EventDetailsPage: React.FC = () => {
hasLikes: false, hasLikes: false,
hasFavorites: false, hasFavorites: false,
hasComments: false, hasComments: false,
colorLabels: [],
myColorLabels: [],
logic: 'AND' logic: 'AND'
}); });
@@ -133,6 +137,8 @@ export const EventDetailsPage: React.FC = () => {
hasFavorites: feedbackFilters.hasFavorites || undefined, hasFavorites: feedbackFilters.hasFavorites || undefined,
hasComments: feedbackFilters.hasComments || undefined, hasComments: feedbackFilters.hasComments || undefined,
minRating: feedbackFilters.minRating ?? undefined, minRating: feedbackFilters.minRating ?? undefined,
colorLabels: feedbackFilters.colorLabels?.length ? feedbackFilters.colorLabels : undefined,
myColorLabels: feedbackFilters.myColorLabels?.length ? feedbackFilters.myColorLabels : undefined,
logic: feedbackFilters.logic, logic: feedbackFilters.logic,
}), [photoFilters, feedbackFilters]); }), [photoFilters, feedbackFilters]);
+60 -2
View File
@@ -7,6 +7,55 @@ export type IdentityMode = 'simple' | 'guest';
export const REACTION_EMOJIS = ['❤️', '😂', '😍', '👏', '🎉'] as const; export const REACTION_EMOJIS = ['❤️', '😂', '😍', '👏', '🎉'] as const;
export type ReactionEmoji = (typeof REACTION_EMOJIS)[number]; export type ReactionEmoji = (typeof REACTION_EMOJIS)[number];
// Colour labels (#1044): Lightroom's colour set, so a client's proofing
// selection round-trips into the photographer's catalogue through xmp:Label.
// Mirrored in backend/src/constants/colorLabels.js — update both together.
export const COLOR_LABELS = ['red', 'yellow', 'green', 'blue', 'purple'] as const;
export type ColorLabel = (typeof COLOR_LABELS)[number];
/** Which lightbox keyboard scheme a gallery uses. */
export type KeybindMode = 'colors' | 'lightroom';
/**
* The two keyboard schemes, as data the gallery lightbox, the admin viewer
* and the settings preview all read these, so they cannot drift.
*
* 'colors' three keys, no Lightroom knowledge needed (discussion #1027):
* 1 = 1st choice, 2 = 2nd choice, 3 = rejected.
* 'lightroom' Lightroom's own defaults: 1-5 stars, 6-9 colours. Lightroom has
* no default shortcut for purple and neither do we.
*
* In both schemes the same key again clears the value.
*/
export const KEYBIND_SCHEMES: Record<KeybindMode, {
colors: Record<string, ColorLabel>;
ratings: Record<string, number>;
}> = {
colors: {
colors: { '1': 'green', '2': 'yellow', '3': 'red' },
ratings: {},
},
lightroom: {
colors: { '6': 'red', '7': 'yellow', '8': 'green', '9': 'blue' },
ratings: { '1': 1, '2': 2, '3': 3, '4': 4, '5': 5 },
},
};
/**
* Swatch colours for the five labels. Deliberately literal hex rather than
* theme variables: these ARE Lightroom's colours, and a gallery theme must
* not repaint "green" into something the photographer can't match in their
* catalogue. Each pairs a fill with a border that stays visible on both a
* white and a black backdrop.
*/
export const COLOR_LABEL_SWATCHES: Record<ColorLabel, { fill: string; ring: string }> = {
red: { fill: '#e8493f', ring: '#b3251c' },
yellow: { fill: '#e8c33f', ring: '#a8871a' },
green: { fill: '#4caf50', ring: '#2e7d32' },
blue: { fill: '#3f7fe8', ring: '#1c4fb3' },
purple: { fill: '#9b59d0', ring: '#6a2f99' },
};
export interface FeedbackSettings { export interface FeedbackSettings {
feedback_enabled: boolean; feedback_enabled: boolean;
allow_ratings: boolean; allow_ratings: boolean;
@@ -14,6 +63,9 @@ export interface FeedbackSettings {
allow_comments: boolean; allow_comments: boolean;
allow_favorites: boolean; allow_favorites: boolean;
allow_reactions: boolean; allow_reactions: boolean;
allow_color_labels: boolean;
/** Which lightbox shortcut scheme this gallery uses (#1044). */
keybind_mode?: KeybindMode;
require_name_email: boolean; require_name_email: boolean;
moderate_comments: boolean; moderate_comments: boolean;
show_feedback_to_guests: boolean; show_feedback_to_guests: boolean;
@@ -34,11 +86,12 @@ export interface PhotoFeedback {
id: number; id: number;
photo_id: number; photo_id: number;
event_id: number; event_id: number;
feedback_type: 'rating' | 'like' | 'comment' | 'favorite' | 'reaction'; feedback_type: 'rating' | 'like' | 'comment' | 'favorite' | 'reaction' | 'color_label';
rating?: number; rating?: number;
comment_text?: string; comment_text?: string;
comment?: string; comment?: string;
reaction?: string; reaction?: string;
color_label?: ColorLabel | null;
guest_name?: string; guest_name?: string;
guest_email?: string; guest_email?: string;
is_approved: boolean; is_approved: boolean;
@@ -57,6 +110,7 @@ export interface FeedbackSummary {
like_count: number; like_count: number;
favorite_count: number; favorite_count: number;
reaction_count?: number; reaction_count?: number;
color_label_count?: number;
comment_count: number; comment_count: number;
} }
@@ -65,6 +119,7 @@ export interface MyFeedback {
liked: boolean; liked: boolean;
favorited: boolean; favorited: boolean;
reaction?: string | null; reaction?: string | null;
color_label?: ColorLabel | null;
} }
export interface FeedbackResponse { export interface FeedbackResponse {
@@ -72,6 +127,8 @@ export interface FeedbackResponse {
summary: FeedbackSummary; summary: FeedbackSummary;
/** Per-emoji tallies for the reaction bar (#839), e.g. { '❤️': 3 }. */ /** Per-emoji tallies for the reaction bar (#839), e.g. { '❤️': 3 }. */
reactions?: Record<string, number>; reactions?: Record<string, number>;
/** Per-colour tallies (#1044), e.g. { green: 3 }. */
color_labels?: Partial<Record<ColorLabel, number>>;
my_feedback: MyFeedback; my_feedback: MyFeedback;
pagination?: { pagination?: {
page: number; page: number;
@@ -210,10 +267,11 @@ class FeedbackService {
} }
async submitFeedback(slug: string, photoId: string, feedback: { async submitFeedback(slug: string, photoId: string, feedback: {
feedback_type: 'rating' | 'like' | 'comment' | 'favorite' | 'reaction'; feedback_type: 'rating' | 'like' | 'comment' | 'favorite' | 'reaction' | 'color_label';
rating?: number; rating?: number;
comment_text?: string; comment_text?: string;
reaction?: string; reaction?: string;
color_label?: ColorLabel;
guest_name?: string; guest_name?: string;
guest_email?: string; guest_email?: string;
}) { }) {
+2
View File
@@ -18,6 +18,7 @@ export interface AdminGuestStats {
comments: number; comments: number;
ratings: number; ratings: number;
reactions: number; reactions: number;
color_labels: number;
distinct_photos: number; distinct_photos: number;
} }
@@ -47,6 +48,7 @@ export interface AdminGuestSelections {
rated: Array<{ photo: AdminGuestPhoto; rating: number }>; rated: Array<{ photo: AdminGuestPhoto; rating: number }>;
commented: Array<{ photo: AdminGuestPhoto; comment: string; created_at: string }>; commented: Array<{ photo: AdminGuestPhoto; comment: string; created_at: string }>;
reacted: Array<{ photo: AdminGuestPhoto; reaction: string }>; reacted: Array<{ photo: AdminGuestPhoto; reaction: string }>;
labeled: Array<{ photo: AdminGuestPhoto; color_label: string }>;
} }
export interface AdminGuestDetail { export interface AdminGuestDetail {
+48
View File
@@ -24,6 +24,16 @@ export interface AdminPhoto {
comment_count?: number; comment_count?: number;
like_count?: number; like_count?: number;
favorite_count?: number; favorite_count?: number;
// Colour labels (#1044). `color_labels` is the per-colour tally across all
// guests; `dominant_color_label` is the one the grid badge and the XMP
// export use when several guests disagreed.
color_label_count?: number;
color_labels?: Record<string, number>;
dominant_color_label?: string | null;
// The requesting admin's OWN triage mark (#1044 follow-up) — separate from
// the client's selections above, and never shown in the gallery.
my_rating?: number | null;
my_color_label?: string | null;
} }
export interface PhotoFilters { export interface PhotoFilters {
@@ -37,10 +47,27 @@ export interface PhotoFilters {
hasFavorites?: boolean; hasFavorites?: boolean;
hasComments?: boolean; hasComments?: boolean;
minRating?: number | null; minRating?: number | null;
/** Colour labels to keep, e.g. ['green'] (#1044). */
colorLabels?: string[];
/** Same, against the caller's own marks. */
myColorLabels?: string[];
logic?: 'AND' | 'OR'; logic?: 'AND' | 'OR';
} }
class PhotosService { class PhotosService {
/**
* Set / change / clear the admin's own mark on a photo (#1044 follow-up).
* Omit a field to leave that half alone; pass null to clear it.
*/
async setPhotoMark(
eventId: number,
photoId: number,
mark: { rating?: number | null; color_label?: string | null }
): Promise<{ rating: number | null; color_label: string | null } | null> {
const response = await api.put(`/admin/photos/${eventId}/photos/${photoId}/mark`, mark);
return response.data.mark;
}
async getEventPhotos(eventId: number, filters?: PhotoFilters): Promise<AdminPhoto[]> { async getEventPhotos(eventId: number, filters?: PhotoFilters): Promise<AdminPhoto[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -59,6 +86,12 @@ class PhotosService {
if (filters.minRating !== undefined && filters.minRating !== null) { if (filters.minRating !== undefined && filters.minRating !== null) {
params.append('min_rating', filters.minRating.toString()); params.append('min_rating', filters.minRating.toString());
} }
if (filters.colorLabels && filters.colorLabels.length > 0) {
params.append('color_label', filters.colorLabels.join(','));
}
if (filters.myColorLabels && filters.myColorLabels.length > 0) {
params.append('my_color_label', filters.myColorLabels.join(','));
}
if (filters.logic) params.append('logic', filters.logic); if (filters.logic) params.append('logic', filters.logic);
} }
@@ -245,6 +278,12 @@ class PhotosService {
if (filters.hasLikes) params.append('has_likes', 'true'); if (filters.hasLikes) params.append('has_likes', 'true');
if (filters.hasFavorites) params.append('has_favorites', 'true'); if (filters.hasFavorites) params.append('has_favorites', 'true');
if (filters.hasComments) params.append('has_comments', 'true'); if (filters.hasComments) params.append('has_comments', 'true');
if (filters.colorLabels && filters.colorLabels.length > 0) {
params.append('color_labels', filters.colorLabels.join(','));
}
if (filters.myColorLabels && filters.myColorLabels.length > 0) {
params.append('my_color_labels', filters.myColorLabels.join(','));
}
if (filters.categoryId) params.append('category_id', filters.categoryId.toString()); if (filters.categoryId) params.append('category_id', filters.categoryId.toString());
if (filters.logic) params.append('logic', filters.logic); if (filters.logic) params.append('logic', filters.logic);
if (filters.sort) params.append('sort', filters.sort); if (filters.sort) params.append('sort', filters.sort);
@@ -339,6 +378,10 @@ export interface FeedbackFilters {
hasFavorites?: boolean; hasFavorites?: boolean;
minFavorites?: number; minFavorites?: number;
hasComments?: boolean; hasComments?: boolean;
/** Colour labels to keep, e.g. ['green'] (#1044). Empty = no filtering. */
colorLabels?: string[];
/** Same, against the caller's own marks. */
myColorLabels?: string[];
categoryId?: number; categoryId?: number;
logic?: 'AND' | 'OR'; logic?: 'AND' | 'OR';
sort?: 'rating' | 'likes' | 'favorites' | 'date' | 'filename'; sort?: 'rating' | 'likes' | 'favorites' | 'date' | 'filename';
@@ -353,6 +396,11 @@ export interface FilterSummary {
withLikes: number; withLikes: number;
withFavorites: number; withFavorites: number;
withComments: number; withComments: number;
withColorLabels?: number;
/** Photos per colour (#1044), e.g. { green: 42 }. */
colorLabelCounts?: Record<string, number>;
/** Same, for the caller's own marks. */
myColorLabelCounts?: Record<string, number>;
} }
export interface FilteredPhotosResponse { export interface FilteredPhotosResponse {
@@ -95,6 +95,14 @@ export interface PublicSettings {
event_require_expiration?: boolean; event_require_expiration?: boolean;
event_default_require_password?: boolean; event_default_require_password?: boolean;
event_default_feedback_enabled?: boolean; event_default_feedback_enabled?: boolean;
// Per-type feedback defaults (#1044) — seed the create form's feedback panel.
event_default_allow_ratings?: boolean;
event_default_allow_likes?: boolean;
event_default_allow_favorites?: boolean;
event_default_allow_comments?: boolean;
event_default_allow_reactions?: boolean;
event_default_allow_color_labels?: boolean;
event_default_keybind_mode?: 'colors' | 'lightroom';
gallery_show_filter_bar?: boolean; gallery_show_filter_bar?: boolean;
event_phone_field_enabled?: boolean; event_phone_field_enabled?: boolean;
// SEO meta tags (consumed by RobotsMetaTags) // SEO meta tags (consumed by RobotsMetaTags)
+6
View File
@@ -208,6 +208,12 @@ export interface Photo {
// Used to seed the lifted likedPhotoIds Set in grid layouts on mount. // Used to seed the lifted likedPhotoIds Set in grid layouts on mount.
is_liked?: boolean; is_liked?: boolean;
favorite_count?: number; favorite_count?: number;
// Colour labels (#1044). `color_label_count` is aggregate data and follows
// show_feedback_to_guests; `my_color_label` is the requesting viewer's own
// label and is always present, so the grid badge survives a refresh even in
// galleries where feedback isn't shared between guests.
color_label_count?: number;
my_color_label?: string | null;
} }
// Download resolutions (#858). // Download resolutions (#858).
@@ -0,0 +1,129 @@
import { describe, it, expect } from 'vitest';
import {
resolveFeedbackKey,
colorShortcutHints,
isTypingTarget,
} from '../feedbackKeybinds';
/**
* Proofing shortcuts (#1044). Two schemes share the same digit keys, so the
* mapping is the whole feature and the guards matter as much as the map:
* a bare digit must not relabel a photo while someone is typing into the
* filename search, and Cmd+1 must stay a browser tab switch.
*/
const key = (k: string, init: Partial<KeyboardEventInit> = {}) =>
new KeyboardEvent('keydown', { key: k, ...init });
const ALL_ON = { allowColorLabels: true, allowRatings: true } as const;
describe('resolveFeedbackKey — colours-only scheme', () => {
const opts = { mode: 'colors' as const, ...ALL_ON };
it('maps 1/2/3 to 1st choice / 2nd choice / rejected', () => {
expect(resolveFeedbackKey(key('1'), opts)).toEqual({ type: 'color', color: 'green' });
expect(resolveFeedbackKey(key('2'), opts)).toEqual({ type: 'color', color: 'yellow' });
expect(resolveFeedbackKey(key('3'), opts)).toEqual({ type: 'color', color: 'red' });
});
it('leaves 4-9 unbound even when ratings are enabled', () => {
for (const k of ['4', '5', '6', '7', '8', '9']) {
expect(resolveFeedbackKey(key(k), opts)).toBeNull();
}
});
it('clears the colour with 0', () => {
expect(resolveFeedbackKey(key('0'), opts)).toEqual({ type: 'clear' });
});
});
describe('resolveFeedbackKey — Lightroom scheme', () => {
const opts = { mode: 'lightroom' as const, ...ALL_ON };
it('maps 1-5 to star ratings', () => {
for (const n of [1, 2, 3, 4, 5]) {
expect(resolveFeedbackKey(key(String(n)), opts)).toEqual({ type: 'rating', value: n });
}
});
it('maps 6-9 to red / yellow / green / blue', () => {
expect(resolveFeedbackKey(key('6'), opts)).toEqual({ type: 'color', color: 'red' });
expect(resolveFeedbackKey(key('7'), opts)).toEqual({ type: 'color', color: 'yellow' });
expect(resolveFeedbackKey(key('8'), opts)).toEqual({ type: 'color', color: 'green' });
expect(resolveFeedbackKey(key('9'), opts)).toEqual({ type: 'color', color: 'blue' });
});
it('clears the rating with 0, matching Lightroom', () => {
expect(resolveFeedbackKey(key('0'), opts)).toEqual({ type: 'rating', value: 0 });
});
});
describe('resolveFeedbackKey — gating', () => {
it('ignores colour keys when colour labels are off', () => {
expect(resolveFeedbackKey(key('1'), {
mode: 'colors', allowColorLabels: false, allowRatings: true,
})).toBeNull();
});
it('ignores star keys when ratings are off', () => {
expect(resolveFeedbackKey(key('4'), {
mode: 'lightroom', allowColorLabels: true, allowRatings: false,
})).toBeNull();
// …but the colour keys of the same scheme still work.
expect(resolveFeedbackKey(key('8'), {
mode: 'lightroom', allowColorLabels: true, allowRatings: false,
})).toEqual({ type: 'color', color: 'green' });
});
it('falls back to the colours scheme for an unknown mode', () => {
expect(resolveFeedbackKey(key('1'), {
mode: 'nonsense' as unknown as 'colors', ...ALL_ON,
})).toEqual({ type: 'color', color: 'green' });
});
it('never fires with a modifier held — Cmd+1 stays a tab switch', () => {
const opts = { mode: 'colors' as const, ...ALL_ON };
expect(resolveFeedbackKey(key('1', { metaKey: true }), opts)).toBeNull();
expect(resolveFeedbackKey(key('1', { ctrlKey: true }), opts)).toBeNull();
expect(resolveFeedbackKey(key('1', { altKey: true }), opts)).toBeNull();
});
it('never fires while the user is typing', () => {
const opts = { mode: 'colors' as const, ...ALL_ON };
for (const tag of ['input', 'textarea', 'select']) {
const element = document.createElement(tag);
const event = key('1');
Object.defineProperty(event, 'target', { value: element });
expect(resolveFeedbackKey(event, opts)).toBeNull();
}
const editable = document.createElement('div');
editable.contentEditable = 'true';
// jsdom doesn't derive isContentEditable from the attribute.
Object.defineProperty(editable, 'isContentEditable', { value: true });
const event = key('1');
Object.defineProperty(event, 'target', { value: editable });
expect(resolveFeedbackKey(event, opts)).toBeNull();
});
});
describe('isTypingTarget', () => {
it('is false for null and for ordinary elements', () => {
expect(isTypingTarget(null)).toBe(false);
expect(isTypingTarget(document.createElement('div'))).toBe(false);
});
});
describe('colorShortcutHints', () => {
it('reports the keys the active scheme actually binds', () => {
expect(colorShortcutHints('colors')).toEqual({ green: '1', yellow: '2', red: '3' });
expect(colorShortcutHints('lightroom')).toEqual({
red: '6', yellow: '7', green: '8', blue: '9',
});
});
it('never claims a shortcut for purple — Lightroom has none either', () => {
expect(colorShortcutHints('colors').purple).toBeUndefined();
expect(colorShortcutHints('lightroom').purple).toBeUndefined();
});
});
+85
View File
@@ -0,0 +1,85 @@
import { KEYBIND_SCHEMES, type ColorLabel, type KeybindMode } from '../services/feedback.service';
/**
* Lightbox keyboard shortcuts for proofing (#1044).
*
* Shared by the gallery lightbox and the admin photo viewer two components
* with independent key handlers that would otherwise drift the moment either
* one gained a shortcut.
*/
export type FeedbackKeyAction =
| { type: 'color'; color: ColorLabel }
| { type: 'rating'; value: number }
| { type: 'clear' };
interface ResolveOptions {
mode: KeybindMode;
allowColorLabels: boolean;
allowRatings: boolean;
}
/**
* True when the event came from somewhere a digit is real input a search
* box, a comment field, a contenteditable. Without this, typing "2024" into
* the filename search would relabel the open photo.
*/
export function isTypingTarget(target: EventTarget | null): boolean {
const element = target as HTMLElement | null;
if (!element || typeof element.tagName !== 'string') return false;
const tag = element.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
return element.isContentEditable === true;
}
/**
* Map a keydown to a proofing action, or null when the key isn't bound in the
* active scheme (the caller's other shortcuts then get their turn).
*
* Modified keys are never bound: Ctrl+1 / Cmd+1 switch browser tabs, and Alt
* combinations are OS shortcuts.
*/
export function resolveFeedbackKey(
event: KeyboardEvent,
{ mode, allowColorLabels, allowRatings }: ResolveOptions
): FeedbackKeyAction | null {
if (event.ctrlKey || event.metaKey || event.altKey) return null;
if (isTypingTarget(event.target)) return null;
const scheme = KEYBIND_SCHEMES[mode] || KEYBIND_SCHEMES.colors;
const key = event.key;
if (allowColorLabels) {
const color = scheme.colors[key];
if (color) return { type: 'color', color };
}
if (allowRatings) {
const rating = scheme.ratings[key];
if (rating !== undefined) return { type: 'rating', value: rating };
}
// '0' clears whichever value the scheme is primarily about: the star rating
// in Lightroom mode (matching Lightroom itself), the colour label in
// colour-only mode, where there are no stars to clear.
if (key === '0') {
if (mode === 'lightroom' && allowRatings) return { type: 'rating', value: 0 };
if (allowColorLabels) return { type: 'clear' };
if (allowRatings) return { type: 'rating', value: 0 };
}
return null;
}
/**
* Which key sets which colour in the active scheme, for the hints rendered on
* the swatches e.g. { green: '1', yellow: '2', red: '3' }.
*/
export function colorShortcutHints(mode: KeybindMode): Partial<Record<ColorLabel, string>> {
const scheme = KEYBIND_SCHEMES[mode] || KEYBIND_SCHEMES.colors;
const hints: Partial<Record<ColorLabel, string>> = {};
for (const [key, color] of Object.entries(scheme.colors)) {
hints[color] = key;
}
return hints;
}