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:
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -25,6 +25,24 @@ function generateGuestIdentifier(req) {
|
||||
.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
|
||||
*/
|
||||
@@ -37,13 +55,7 @@ async function getRateLimitSettings() {
|
||||
// Defaults FIRST, stored values override: persisted rows predate newer
|
||||
// action types (`reaction`, #839) — returning the stored object alone
|
||||
// would silently drop their intended defaults to the generic 100/h.
|
||||
const defaults = {
|
||||
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)
|
||||
};
|
||||
const defaults = { ...DEFAULT_RATE_LIMITS };
|
||||
|
||||
if (settings && settings.setting_value) {
|
||||
// setting_value is already a JSON object in PostgreSQL
|
||||
@@ -57,13 +69,7 @@ async function getRateLimitSettings() {
|
||||
} catch (error) {
|
||||
logger.error('Error getting rate limit settings:', error);
|
||||
// Return defaults on error
|
||||
return {
|
||||
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 }
|
||||
};
|
||||
return { ...DEFAULT_RATE_LIMITS };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { clampIntOrUndefined } = require('../../utils/numericHelpers');
|
||||
const { getFrontendBaseUrl, getAbsoluteFrontendUrl } = require('../../utils/frontendUrl');
|
||||
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');
|
||||
|
||||
module.exports = (router) => {
|
||||
@@ -93,6 +94,15 @@ module.exports = (router) => {
|
||||
// #328 follow-up: per-event opt-in for presigned-URL "Download All".
|
||||
// Bypasses watermarks; admin must enable knowingly.
|
||||
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(),
|
||||
// Hero logo settings
|
||||
body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
|
||||
@@ -167,13 +177,20 @@ module.exports = (router) => {
|
||||
watermark_text = null,
|
||||
allow_presigned_download = false,
|
||||
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,
|
||||
allow_ratings = true,
|
||||
allow_likes = true,
|
||||
allow_comments = true,
|
||||
allow_favorites = true,
|
||||
allow_reactions = true,
|
||||
allow_ratings: allowRatingsInput,
|
||||
allow_likes: allowLikesInput,
|
||||
allow_comments: allowCommentsInput,
|
||||
allow_favorites: allowFavoritesInput,
|
||||
allow_reactions: allowReactionsInput,
|
||||
allow_color_labels: allowColorLabelsInput,
|
||||
keybind_mode: keybindModeInput,
|
||||
require_name_email = false,
|
||||
moderate_comments = true,
|
||||
show_feedback_to_guests = true,
|
||||
@@ -253,6 +270,18 @@ module.exports = (router) => {
|
||||
}
|
||||
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
|
||||
logger.debug('Download control values', {
|
||||
allow_downloads,
|
||||
@@ -510,11 +539,13 @@ module.exports = (router) => {
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId,
|
||||
feedback_enabled: formatBoolean(feedback_enabled),
|
||||
allow_ratings: formatBoolean(allow_ratings),
|
||||
allow_likes: formatBoolean(allow_likes),
|
||||
allow_comments: formatBoolean(allow_comments),
|
||||
allow_favorites: formatBoolean(allow_favorites),
|
||||
allow_reactions: formatBoolean(allow_reactions),
|
||||
allow_ratings: formatBoolean(feedbackDefaults.allow_ratings),
|
||||
allow_likes: formatBoolean(feedbackDefaults.allow_likes),
|
||||
allow_comments: formatBoolean(feedbackDefaults.allow_comments),
|
||||
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(require_name_email),
|
||||
moderate_comments: formatBoolean(moderate_comments),
|
||||
show_feedback_to_guests: formatBoolean(show_feedback_to_guests),
|
||||
@@ -1157,6 +1188,10 @@ module.exports = (router) => {
|
||||
allow_comments: sourceFeedback.allow_comments,
|
||||
allow_favorites: sourceFeedback.allow_favorites,
|
||||
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,
|
||||
moderate_comments: sourceFeedback.moderate_comments,
|
||||
show_feedback_to_guests: sourceFeedback.show_feedback_to_guests,
|
||||
|
||||
@@ -262,6 +262,7 @@ router.get('/events/:eventId/feedback-analytics',
|
||||
total_comments: Number(summaryData.stats?.total_comments) || 0,
|
||||
total_favorites: Number(summaryData.stats?.total_favorites) || 0,
|
||||
total_reactions: Number(summaryData.stats?.total_reactions) || 0,
|
||||
total_color_labels: Number(summaryData.stats?.total_color_labels) || 0,
|
||||
};
|
||||
const summary = {
|
||||
average_rating: parseFloat(avgRatingResult?.average_rating || 0),
|
||||
@@ -269,7 +270,7 @@ router.get('/events/:eventId/feedback-analytics',
|
||||
pending_moderation: Number(pendingModeration?.count) || 0,
|
||||
total_feedback: counts.total_ratings + counts.total_likes +
|
||||
counts.total_comments + counts.total_favorites +
|
||||
counts.total_reactions
|
||||
counts.total_reactions + counts.total_color_labels
|
||||
};
|
||||
|
||||
// Get top-rated photos
|
||||
|
||||
@@ -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 = \'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 = \'color_label\' THEN 1 END) AS color_labels'),
|
||||
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
|
||||
)
|
||||
.orderBy('gallery_guests.created_at', 'desc');
|
||||
@@ -410,6 +411,7 @@ router.get(
|
||||
'photo_feedback.rating',
|
||||
'photo_feedback.comment_text',
|
||||
'photo_feedback.reaction',
|
||||
'photo_feedback.color_label',
|
||||
'photo_feedback.created_at',
|
||||
'photos.id as photo_id',
|
||||
'photos.filename',
|
||||
@@ -433,6 +435,7 @@ router.get(
|
||||
rated: [],
|
||||
commented: [],
|
||||
reacted: [],
|
||||
labeled: [],
|
||||
};
|
||||
for (const row of feedback) {
|
||||
if (row.feedback_type === 'like') {
|
||||
@@ -449,6 +452,8 @@ router.get(
|
||||
});
|
||||
} else if (row.feedback_type === '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,
|
||||
ratings: selections.rated.length,
|
||||
reactions: selections.reacted.length,
|
||||
color_labels: selections.labeled.length,
|
||||
},
|
||||
},
|
||||
selections,
|
||||
|
||||
@@ -13,6 +13,7 @@ const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
const { PhotoExportService } = require('../services/photoExportService');
|
||||
const photoAdminMarksService = require('../services/photoAdminMarksService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const exportService = new PhotoExportService();
|
||||
@@ -29,6 +30,8 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), re
|
||||
query('has_favorites').optional().isBoolean(),
|
||||
query('min_favorites').optional().isInt({ min: 0 }),
|
||||
query('has_comments').optional().isBoolean(),
|
||||
query('color_labels').optional().isString(),
|
||||
query('my_color_labels').optional().isString(),
|
||||
query('category_id').optional().isInt(),
|
||||
query('logic').optional().isIn(['AND', 'OR']),
|
||||
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,
|
||||
min_favorites: req.query.min_favorites ? parseInt(req.query.min_favorites) : undefined,
|
||||
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,
|
||||
logic: req.query.logic || 'AND'
|
||||
};
|
||||
@@ -84,6 +90,7 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), re
|
||||
'photos.like_count',
|
||||
'photos.favorite_count',
|
||||
'photos.comment_count',
|
||||
'photos.color_label_count',
|
||||
'photos.width',
|
||||
'photos.height',
|
||||
'photos.uploaded_at',
|
||||
@@ -142,9 +149,15 @@ router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view
|
||||
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({
|
||||
success: true,
|
||||
data: summary
|
||||
data: { ...summary, myColorLabelCounts }
|
||||
});
|
||||
} catch (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('filter').optional().isObject(),
|
||||
body('format').isIn(['txt', 'csv', 'xmp', 'json']),
|
||||
body('options.mark_source').optional().isIn(['client', 'mine']),
|
||||
body('options').optional().isObject()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
@@ -189,13 +203,18 @@ router.post('/:eventId/export', adminAuth, requirePermission('photos.download'),
|
||||
db('photos').select('id'),
|
||||
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());
|
||||
photoIds = filteredPhotos.map(p => p.id);
|
||||
}
|
||||
|
||||
// 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') {
|
||||
res.setHeader('Content-Type', result.contentType);
|
||||
|
||||
@@ -13,6 +13,9 @@ const {
|
||||
pickRawDownloadName,
|
||||
} = require('../services/downloadFilenameService');
|
||||
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 { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings');
|
||||
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)
|
||||
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), requireEventOwnership, async (req, res) => {
|
||||
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) => {
|
||||
try {
|
||||
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 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));
|
||||
}
|
||||
}
|
||||
// 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 (logic === 'OR') {
|
||||
query = query.where(builder => {
|
||||
@@ -1132,6 +1224,20 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
|
||||
commentCounts.forEach(c => {
|
||||
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({
|
||||
photos: photos.map(photo => ({
|
||||
@@ -1159,6 +1265,13 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
|
||||
comment_count: commentMap[photo.id] || 0,
|
||||
like_count: photo.like_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
|
||||
// this explicit mapper never included them — so the Engagement
|
||||
// column showed 0 regardless of what the DB counted. This, not
|
||||
|
||||
@@ -31,6 +31,7 @@ const { verifyGalleryAccess, denySlideshowToken, isAdminPreview } = require('../
|
||||
const withPreview = (req, url) => (req.isAdminPreview ? `${url}${url.includes('?') ? '&' : '?'}admin_preview=1` : url);
|
||||
const { resolveGuest } = require('../middleware/guestAuth');
|
||||
const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||
const { COLOR_LABELS } = require('../constants/colorLabels');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { pipeStreamToResponse } = require('../utils/streamResponse');
|
||||
@@ -721,10 +722,11 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
};
|
||||
|
||||
let guestFeedbackByType = null;
|
||||
let guestColorLabels = null;
|
||||
if (guest_id) {
|
||||
const guestFeedbackRows = await db('photo_feedback')
|
||||
.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) => {
|
||||
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);
|
||||
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) => {
|
||||
@@ -757,6 +768,23 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
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')) {
|
||||
includeGuestMatches('comment');
|
||||
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));
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
@@ -1087,6 +1135,12 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
// grid seed its lifted likedPhotoIds correctly on hard refresh.
|
||||
is_liked: showFeedbackToGuests ? likedPhotoIds.has(photo.id) : false,
|
||||
favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0,
|
||||
// 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
|
||||
// off for this event or hidden from guests, so the frontend has
|
||||
// one shape to handle. Riding along on this payload is what keeps
|
||||
|
||||
@@ -33,6 +33,9 @@ router.get('/:slug/feedback-settings',
|
||||
allow_comments: Boolean(settings.allow_comments),
|
||||
allow_favorites: Boolean(settings.allow_favorites),
|
||||
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),
|
||||
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests),
|
||||
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
|
||||
// a new surface, kept fully hidden while sharing is off.
|
||||
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')
|
||||
.where({
|
||||
photo_id: photoId,
|
||||
@@ -142,11 +146,17 @@ router.get('/:slug/photos/:photoId/feedback',
|
||||
reactions: settings.show_feedback_to_guests
|
||||
? 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: {
|
||||
rating: guestFeedback.find(f => f.feedback_type === 'rating')?.rating,
|
||||
liked: !!guestFeedback.find(f => f.feedback_type === 'like'),
|
||||
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) {
|
||||
@@ -199,7 +209,8 @@ router.post('/:slug/photos/:photoId/feedback',
|
||||
like: settings.allow_likes,
|
||||
comment: settings.allow_comments,
|
||||
favorite: settings.allow_favorites,
|
||||
reaction: settings.allow_reactions
|
||||
reaction: settings.allow_reactions,
|
||||
color_label: settings.allow_color_labels
|
||||
};
|
||||
|
||||
if (!typeAllowed[feedbackType]) {
|
||||
@@ -246,6 +257,7 @@ router.post('/:slug/photos/:photoId/feedback',
|
||||
rating: req.body.rating,
|
||||
comment_text: req.body.comment_text,
|
||||
reaction: req.body.reaction,
|
||||
color_label: req.body.color_label,
|
||||
guest_name: req.guest?.name ?? req.body.guest_name,
|
||||
guest_email: req.guest?.email ?? req.body.guest_email,
|
||||
guest_id: req.guest?.id ?? null,
|
||||
@@ -372,7 +384,9 @@ router.get('/:slug/feedback-summary',
|
||||
allow_likes: settings.allow_likes,
|
||||
allow_comments: settings.allow_comments,
|
||||
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
|
||||
});
|
||||
|
||||
@@ -18,6 +18,16 @@ router.get('/', async (req, res) => {
|
||||
'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai',
|
||||
'event_default_require_password',
|
||||
'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',
|
||||
'event_phone_field_enabled',
|
||||
// #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
|
||||
// existing installs see no behaviour change until an admin flips it.
|
||||
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).
|
||||
event_phone_field_enabled: settingsObject.event_phone_field_enabled === 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)
|
||||
// 3. slug probe
|
||||
// 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 brandingChain = buildChain({ selectResult: [] });
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 50 }] });
|
||||
const feedbackDefaultsChain = buildChain({ selectResult: [] });
|
||||
const feedbackInsertChain = buildChain();
|
||||
db.__setImplementations(devtoolsChain, brandingChain, slugChain, insertChain, feedbackInsertChain);
|
||||
db.__setImplementations(
|
||||
devtoolsChain, brandingChain, slugChain, insertChain,
|
||||
feedbackDefaultsChain, feedbackInsertChain,
|
||||
);
|
||||
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, feedback_enabled: true })
|
||||
.expect(201);
|
||||
|
||||
expect(db).toHaveBeenNthCalledWith(5, 'event_feedback_settings');
|
||||
expect(db).toHaveBeenNthCalledWith(6, 'event_feedback_settings');
|
||||
|
||||
const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0];
|
||||
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_comments)).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.moderate_comments)).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 () => {
|
||||
// Feedback probe returns serialized "true" → fallback kicks in and
|
||||
// the feedback insert runs. Sequence: feedback probe, devtools probe,
|
||||
// branding probe, slug, insert, feedback insert (6 calls total).
|
||||
// branding probe, slug, insert, sub-toggle defaults probe (#1044),
|
||||
// feedback insert (7 calls total).
|
||||
const feedbackProbe = buildChain({
|
||||
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 slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 51 }] });
|
||||
const feedbackDefaultsChain = buildChain({ selectResult: [] });
|
||||
const feedbackInsertChain = buildChain();
|
||||
db.__setImplementations(
|
||||
feedbackProbe, devtoolsChain, brandingChain, slugChain, insertChain, feedbackInsertChain
|
||||
feedbackProbe, devtoolsChain, brandingChain, slugChain, insertChain,
|
||||
feedbackDefaultsChain, feedbackInsertChain,
|
||||
);
|
||||
|
||||
await request(buildApp())
|
||||
@@ -223,7 +237,7 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
|
||||
.send(BASE_BODY)
|
||||
.expect(201);
|
||||
|
||||
expect(db).toHaveBeenNthCalledWith(6, 'event_feedback_settings');
|
||||
expect(db).toHaveBeenNthCalledWith(7, 'event_feedback_settings');
|
||||
expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const { requireEventOwnership, scopeEventsQuery } = require('../../middleware/ow
|
||||
// requirePermission gates supply the missing half; they key on req.admin.id,
|
||||
// which apiTokenAuth populates.
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const { resolveEventFeedbackDefaults } = require('../../services/feedbackDefaults');
|
||||
const { buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||
const { generateThumbnail } = require('../../services/imageProcessor');
|
||||
const logger = require('../../utils/logger');
|
||||
@@ -303,17 +304,22 @@ router.post(
|
||||
const id = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
// Issue #550 — mirror adminEvents.js: create event_feedback_settings
|
||||
// row when feedback is enabled, so the gallery actually shows
|
||||
// feedback UI. Sub-flags default to the same values the admin form
|
||||
// ships with (everything on except require_name_email).
|
||||
// row when feedback is enabled, so the gallery actually shows feedback
|
||||
// UI. The sub-flags come from the shared global defaults (#1044) rather
|
||||
// than a hard-coded list, which is how this path silently shipped
|
||||
// without allow_reactions for two releases.
|
||||
if (feedback_enabled) {
|
||||
const feedbackDefaults = await resolveEventFeedbackDefaults();
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: id,
|
||||
feedback_enabled: formatBoolean(true),
|
||||
allow_ratings: formatBoolean(true),
|
||||
allow_likes: formatBoolean(true),
|
||||
allow_comments: formatBoolean(true),
|
||||
allow_favorites: formatBoolean(true),
|
||||
allow_ratings: formatBoolean(feedbackDefaults.allow_ratings),
|
||||
allow_likes: formatBoolean(feedbackDefaults.allow_likes),
|
||||
allow_comments: formatBoolean(feedbackDefaults.allow_comments),
|
||||
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),
|
||||
moderate_comments: formatBoolean(true),
|
||||
show_feedback_to_guests: formatBoolean(true),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -2,6 +2,8 @@ const { db, logActivity } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
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
|
||||
// posts its whole client-side state back, including UI-only keys that were
|
||||
@@ -17,6 +19,8 @@ const FEEDBACK_SETTINGS_COLUMNS = [
|
||||
'allow_comments',
|
||||
'allow_favorites',
|
||||
'allow_reactions',
|
||||
'allow_color_labels',
|
||||
'keybind_mode',
|
||||
'require_name_email',
|
||||
'moderate_comments',
|
||||
'require_moderation',
|
||||
@@ -26,6 +30,16 @@ const FEEDBACK_SETTINGS_COLUMNS = [
|
||||
'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) {
|
||||
const picked = {};
|
||||
for (const column of FEEDBACK_SETTINGS_COLUMNS) {
|
||||
@@ -47,15 +61,15 @@ class FeedbackService {
|
||||
.first();
|
||||
|
||||
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 {
|
||||
event_id: eventId,
|
||||
feedback_enabled: false,
|
||||
allow_ratings: true,
|
||||
allow_likes: true,
|
||||
allow_comments: false,
|
||||
allow_favorites: true,
|
||||
allow_reactions: true,
|
||||
...globals,
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true,
|
||||
@@ -69,6 +83,12 @@ class FeedbackService {
|
||||
if (!settings.identity_mode) {
|
||||
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
|
||||
// layer treats null/0/missing identically.
|
||||
settings.max_favorites_per_guest = settings.max_favorites_per_guest ?? null;
|
||||
@@ -139,10 +159,10 @@ class FeedbackService {
|
||||
|
||||
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
|
||||
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
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -152,6 +172,11 @@ class FeedbackService {
|
||||
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
|
||||
// sentinel (0, or "0" from callers that skip the route validator's
|
||||
// toInt) triggers the destructive path — malformed input (undefined,
|
||||
@@ -209,35 +234,41 @@ class FeedbackService {
|
||||
return { id: existing.id, updated: true };
|
||||
}
|
||||
|
||||
// One reaction per guest per photo, changeable (#839): the same
|
||||
// emoji again toggles it off; a different one switches the row.
|
||||
// Single-value types — one reaction (#839) and one colour label
|
||||
// (#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:
|
||||
// like the sibling like path, the check-then-insert above can race
|
||||
// into duplicate rows — operating on the set makes the next
|
||||
// interaction collapse them instead of leaving a phantom count.
|
||||
const reactionScope = () => {
|
||||
const q = db('photo_feedback').where({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
feedback_type: 'reaction',
|
||||
});
|
||||
if (guest_id) q.where('guest_id', guest_id);
|
||||
else q.where('guest_identifier', guestIdentifier);
|
||||
return q;
|
||||
};
|
||||
if (feedback_type === 'reaction') {
|
||||
if (existing.reaction === reaction) {
|
||||
await reactionScope().delete();
|
||||
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({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
feedback_type,
|
||||
});
|
||||
if (guest_id) q.where('guest_id', guest_id);
|
||||
else q.where('guest_identifier', guestIdentifier);
|
||||
return q;
|
||||
};
|
||||
|
||||
if (existing[singleValueColumn] === submittedValue) {
|
||||
await singleValueScope().delete();
|
||||
await this.updatePhotoFeedbackStats(photoId);
|
||||
return { removed: true };
|
||||
}
|
||||
// Converge to exactly one row: drop any racy duplicates, then
|
||||
// switch the surviving row's emoji.
|
||||
await reactionScope().whereNot('id', existing.id).delete();
|
||||
// switch the surviving row's value.
|
||||
await singleValueScope().whereNot('id', existing.id).delete();
|
||||
await db('photo_feedback')
|
||||
.where('id', existing.id)
|
||||
.update({
|
||||
reaction,
|
||||
[singleValueColumn]: submittedValue,
|
||||
updated_at: new Date()
|
||||
});
|
||||
await this.updatePhotoFeedbackStats(photoId);
|
||||
@@ -300,6 +331,7 @@ class FeedbackService {
|
||||
rating: feedback_type === 'rating' ? rating : null,
|
||||
comment_text: feedback_type === 'comment' ? comment_text : null,
|
||||
reaction: feedback_type === 'reaction' ? reaction : null,
|
||||
color_label: feedback_type === 'color_label' ? color_label : null,
|
||||
guest_name,
|
||||
guest_email,
|
||||
guest_identifier: guestIdentifier,
|
||||
@@ -352,7 +384,7 @@ class FeedbackService {
|
||||
|
||||
const feedback = await query
|
||||
.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;
|
||||
} catch (error) {
|
||||
@@ -368,7 +400,7 @@ class FeedbackService {
|
||||
try {
|
||||
const photos = await db('photos')
|
||||
.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('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_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_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();
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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 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 color_label_count', ['color_label']),
|
||||
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')
|
||||
)
|
||||
@@ -446,7 +541,8 @@ class FeedbackService {
|
||||
like_count: stats.like_count || 0,
|
||||
average_rating: stats.average_rating || 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) {
|
||||
logger.error('Error updating photo feedback stats:', error);
|
||||
@@ -587,6 +683,7 @@ class FeedbackService {
|
||||
'photo_feedback.rating',
|
||||
'photo_feedback.comment_text',
|
||||
'photo_feedback.reaction',
|
||||
'photo_feedback.color_label',
|
||||
'photo_feedback.guest_name',
|
||||
'photo_feedback.guest_email',
|
||||
'photo_feedback.created_at'
|
||||
@@ -625,6 +722,7 @@ class FeedbackService {
|
||||
'photo_feedback.rating',
|
||||
'photo_feedback.comment_text',
|
||||
'photo_feedback.reaction',
|
||||
'photo_feedback.color_label',
|
||||
'photo_feedback.guest_name',
|
||||
'photo_feedback.guest_email',
|
||||
'photo_feedback.guest_identifier',
|
||||
@@ -651,6 +749,7 @@ class FeedbackService {
|
||||
star_rating: '',
|
||||
comment: '',
|
||||
reaction: '',
|
||||
color_label: '',
|
||||
latest_at: row.created_at,
|
||||
};
|
||||
byKey.set(key, entry);
|
||||
@@ -680,6 +779,9 @@ class FeedbackService {
|
||||
case 'reaction':
|
||||
if (row.reaction) entry.reaction = row.reaction;
|
||||
break;
|
||||
case 'color_label':
|
||||
if (row.color_label) entry.color_label = row.color_label;
|
||||
break;
|
||||
default:
|
||||
// Unknown feedback type — ignore so a future type doesn't break the export.
|
||||
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 };
|
||||
@@ -6,6 +6,9 @@
|
||||
const archiver = require('archiver');
|
||||
const { PassThrough } = require('stream');
|
||||
const { XmpGenerator } = require('./xmpGenerator');
|
||||
const { dominantColorLabel } = require('../constants/colorLabels');
|
||||
const photoAdminMarksService = require('./photoAdminMarksService');
|
||||
const feedbackService = require('./feedbackService');
|
||||
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
|
||||
const { db } = require('../database/db');
|
||||
const path = require('path');
|
||||
@@ -22,7 +25,7 @@ class PhotoExportService {
|
||||
* @param {number[]} photoIds - Photo IDs to export (optional, exports all if not provided)
|
||||
* @returns {Promise<Object[]>} Photos with feedback
|
||||
*/
|
||||
async getPhotosWithFeedback(eventId, photoIds = null) {
|
||||
async getPhotosWithFeedback(eventId, photoIds = null, adminId = null) {
|
||||
let query = db('photos')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.where('photos.event_id', eventId)
|
||||
@@ -36,6 +39,7 @@ class PhotoExportService {
|
||||
'photos.like_count',
|
||||
'photos.favorite_count',
|
||||
'photos.comment_count',
|
||||
'photos.color_label_count',
|
||||
'photos.width',
|
||||
'photos.height',
|
||||
'photos.size_bytes',
|
||||
@@ -48,7 +52,33 @@ class PhotoExportService {
|
||||
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
|
||||
*/
|
||||
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) {
|
||||
throw new Error('No photos to export');
|
||||
@@ -139,6 +171,9 @@ class PhotoExportService {
|
||||
'likes',
|
||||
'favorites',
|
||||
'comments',
|
||||
'color_label',
|
||||
'my_rating',
|
||||
'my_color_label',
|
||||
'category',
|
||||
'width',
|
||||
'height',
|
||||
@@ -154,6 +189,9 @@ class PhotoExportService {
|
||||
photo.like_count || 0,
|
||||
photo.favorite_count || 0,
|
||||
photo.comment_count || 0,
|
||||
photo.dominant_color_label || '',
|
||||
photo.my_rating ?? '',
|
||||
photo.my_color_label || '',
|
||||
photo.category_name || '',
|
||||
photo.width || '',
|
||||
photo.height || '',
|
||||
@@ -181,7 +219,17 @@ class PhotoExportService {
|
||||
* Export as XMP sidecar files in a ZIP archive
|
||||
*/
|
||||
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 passthrough = new PassThrough();
|
||||
@@ -192,7 +240,7 @@ class PhotoExportService {
|
||||
? (photo.original_filename || photo.filename)
|
||||
: photo.filename;
|
||||
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 });
|
||||
}
|
||||
@@ -237,6 +285,10 @@ class PhotoExportService {
|
||||
likes: photo.like_count || 0,
|
||||
favorites: photo.favorite_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: {
|
||||
width: photo.width || null,
|
||||
height: photo.height || null
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Generates Adobe XMP metadata files for photos with guest feedback
|
||||
*/
|
||||
|
||||
const { COLOR_LABEL_TO_XMP, dominantColorLabel } = require('../constants/colorLabels');
|
||||
|
||||
class XmpGenerator {
|
||||
/**
|
||||
* Generate XMP sidecar content for a photo
|
||||
@@ -19,7 +21,7 @@ class XmpGenerator {
|
||||
} = options;
|
||||
|
||||
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 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
|
||||
* @returns {string|null} XMP label color
|
||||
*/
|
||||
mapLabel(avgRating) {
|
||||
mapRatingToLabel(avgRating) {
|
||||
if (!avgRating || avgRating === 0) return null;
|
||||
if (avgRating >= 4.5) return 'Red'; // Top picks
|
||||
if (avgRating >= 3.5) return 'Yellow'; // Good
|
||||
@@ -80,7 +110,9 @@ class XmpGenerator {
|
||||
const likes = photo.like_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>
|
||||
<rdf:Alt>
|
||||
@@ -113,6 +145,14 @@ class XmpGenerator {
|
||||
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) {
|
||||
keywords.push(this.sanitizeKeyword(photo.category_name));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ const { body, param, validationResult } = require('express-validator');
|
||||
const validator = require('validator');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('./emailNormalization');
|
||||
const { REACTION_EMOJIS } = require('../constants/reactions');
|
||||
const { COLOR_LABELS } = require('../constants/colorLabels');
|
||||
const { KEYBIND_MODES } = require('../services/feedbackDefaults');
|
||||
|
||||
/**
|
||||
* Validation rules for feedback submission
|
||||
@@ -134,7 +136,7 @@ function getValidationRules(feedbackType) {
|
||||
*/
|
||||
const validateFeedbackSubmission = [
|
||||
body('feedback_type')
|
||||
.isIn(['rating', 'like', 'comment', 'favorite', 'reaction'])
|
||||
.isIn(['rating', 'like', 'comment', 'favorite', 'reaction', 'color_label'])
|
||||
.withMessage('Invalid feedback type'),
|
||||
|
||||
// Conditional validation based on feedback type. 0 clears the guest's
|
||||
@@ -151,6 +153,14 @@ const validateFeedbackSubmission = [
|
||||
.if(body('feedback_type').equals('reaction'))
|
||||
.custom((value) => REACTION_EMOJIS.includes(value))
|
||||
.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')
|
||||
.if(body('feedback_type').equals('comment'))
|
||||
@@ -194,6 +204,9 @@ const validateFeedbackSettings = [
|
||||
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)
|
||||
.withMessage(`keybind_mode must be one of: ${KEYBIND_MODES.join(', ')}`),
|
||||
body('require_name_email').optional().isBoolean(),
|
||||
body('moderate_comments').optional().isBoolean(),
|
||||
body('show_feedback_to_guests').optional().isBoolean(),
|
||||
|
||||
@@ -3,6 +3,23 @@
|
||||
* 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 {
|
||||
constructor(queryBuilder, eventId) {
|
||||
this.query = queryBuilder;
|
||||
@@ -21,6 +38,9 @@ class PhotoFilterBuilder {
|
||||
has_favorites,
|
||||
min_favorites,
|
||||
has_comments,
|
||||
color_labels,
|
||||
my_color_labels,
|
||||
admin_id,
|
||||
category_id,
|
||||
logic = 'AND'
|
||||
} = filters;
|
||||
@@ -59,6 +79,36 @@ class PhotoFilterBuilder {
|
||||
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) {
|
||||
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 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 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();
|
||||
|
||||
// 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 {
|
||||
total: parseInt(result.total) || 0,
|
||||
withRatings: parseInt(result.with_ratings) || 0,
|
||||
withLikes: parseInt(result.with_likes) || 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 { useQuery } from '@tanstack/react-query';
|
||||
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 { guestsService, AdminGuest } from '../../services/guests.service';
|
||||
import { AuthenticatedImage } from '../common/AuthenticatedImage';
|
||||
@@ -14,13 +14,18 @@ interface AdminGuestDetailProps {
|
||||
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 }) => {
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
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({
|
||||
queryKey: ['admin-guest-detail', 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 commented = selections?.commented || [];
|
||||
const reacted = selections?.reacted || [];
|
||||
const labeled = selections?.labeled || [];
|
||||
|
||||
// "all" view combines the three visual selection types.
|
||||
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'));
|
||||
rated.forEach((r) => add(r.photo, 'rating'));
|
||||
reacted.forEach((r) => add(r.photo, r.reaction));
|
||||
labeled.forEach((r) => add(r.photo, colorBadge(r.color_label)));
|
||||
|
||||
const visibleItems: GridItem[] =
|
||||
tab === 'all'
|
||||
@@ -62,6 +69,8 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
|
||||
? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}★`] }))
|
||||
: tab === 'reacted'
|
||||
? reacted.map((r) => ({ photo: r.photo, badges: [r.reaction] }))
|
||||
: tab === 'labeled'
|
||||
? labeled.map((r) => ({ photo: r.photo, badges: [colorBadge(r.color_label)] }))
|
||||
: [];
|
||||
|
||||
return (
|
||||
@@ -137,11 +146,20 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
|
||||
{t('admin.guests.columns.reactions', 'Reactions')}
|
||||
</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>
|
||||
|
||||
{/* Tabs */}
|
||||
<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
|
||||
key={k}
|
||||
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">
|
||||
{t('admin.guests.columns.reactions', 'Reactions')}
|
||||
</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">
|
||||
{t('admin.guests.columns.lastSeen', 'Last seen')}
|
||||
</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">
|
||||
{guest.stats.reactions}
|
||||
</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">
|
||||
{fmtDate(guest.last_seen_at)}
|
||||
</td>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { COLOR_LABEL_SWATCHES, type ColorLabel } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -465,6 +466,56 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
</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) */}
|
||||
{(commentCount > 0 || averageRating > 0 || likeCount > 0) && (
|
||||
<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 { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
|
||||
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';
|
||||
|
||||
type AdminFeedbackResponse = {
|
||||
@@ -36,6 +39,11 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
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 commentsModal = useModal();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -143,6 +151,52 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
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(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
@@ -155,6 +209,23 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
case 'ArrowRight':
|
||||
goToNext();
|
||||
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>
|
||||
|
||||
{/* 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">1–5</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 */}
|
||||
{feedbackData && (
|
||||
<div className="mt-6 pt-6 border-t border-neutral-700">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { COLOR_LABELS, COLOR_LABEL_SWATCHES, KEYBIND_SCHEMES, type KeybindMode } from '../../services/feedback.service';
|
||||
|
||||
interface FeedbackSettingsProps {
|
||||
settings: FeedbackSettings;
|
||||
@@ -16,6 +17,8 @@ interface FeedbackSettings {
|
||||
allow_comments: boolean;
|
||||
allow_favorites: boolean;
|
||||
allow_reactions: boolean;
|
||||
allow_color_labels: boolean;
|
||||
keybind_mode?: KeybindMode;
|
||||
require_name_email: boolean;
|
||||
moderate_comments: boolean;
|
||||
show_feedback_to_guests: boolean;
|
||||
@@ -238,9 +241,105 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</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.
|
||||
Only renders when the matching toggle is on — the cap is
|
||||
meaningless if the type itself is disabled. */}
|
||||
|
||||
@@ -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 options: ExportOptions = {
|
||||
format,
|
||||
@@ -98,7 +102,8 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
include_rating: true,
|
||||
include_label: 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,
|
||||
min_favorites: filters.minFavorites,
|
||||
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,
|
||||
logic: filters.logic,
|
||||
sort: filters.sort,
|
||||
@@ -134,7 +142,9 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
filters.minRating !== null ||
|
||||
filters.hasLikes ||
|
||||
filters.hasFavorites ||
|
||||
filters.hasComments
|
||||
filters.hasComments ||
|
||||
(filters.colorLabels?.length || 0) > 0 ||
|
||||
(filters.myColorLabels?.length || 0) > 0
|
||||
);
|
||||
|
||||
const isDisabled = disabled || (!hasSelection && !hasFilters);
|
||||
@@ -187,6 +197,22 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
}
|
||||
</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) => {
|
||||
const Icon = format.icon;
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from '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 { Button } from '../common';
|
||||
import { FeedbackFilters, FilterSummary } from '../../services/photos.service';
|
||||
@@ -37,6 +38,31 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
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') => {
|
||||
onChange({ ...filters, logic });
|
||||
};
|
||||
@@ -47,6 +73,8 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
hasLikes: false,
|
||||
hasFavorites: false,
|
||||
hasComments: false,
|
||||
colorLabels: [],
|
||||
myColorLabels: [],
|
||||
logic: 'AND'
|
||||
});
|
||||
};
|
||||
@@ -54,7 +82,9 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
const hasActiveFilters = filters.minRating !== null ||
|
||||
filters.hasLikes ||
|
||||
filters.hasFavorites ||
|
||||
filters.hasComments;
|
||||
filters.hasComments ||
|
||||
(filters.colorLabels?.length || 0) > 0 ||
|
||||
(filters.myColorLabels?.length || 0) > 0;
|
||||
|
||||
return (
|
||||
<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>
|
||||
</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 */}
|
||||
{(filters.hasLikes || filters.hasFavorites || filters.hasComments) && (
|
||||
<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 { useTranslation } from 'react-i18next';
|
||||
import { GalleryFilter, type FilterType, type FeedbackFilterType } from './GalleryFilter';
|
||||
import { ColorLabelFilterChips } from './ColorLabelFilterChips';
|
||||
import type { ColorLabel } from '../../services/feedback.service';
|
||||
|
||||
interface GallerySidebarProps {
|
||||
isOpen: boolean;
|
||||
@@ -38,6 +40,11 @@ interface GallerySidebarProps {
|
||||
likeCount?: number;
|
||||
favoriteCount?: number;
|
||||
ratedCount?: number;
|
||||
// Colour-label filters (#1044).
|
||||
colorLabelsEnabled?: boolean;
|
||||
activeColorFilters?: ColorLabel[];
|
||||
onColorFilterChange?: (color: ColorLabel) => void;
|
||||
colorLabelCounts?: Partial<Record<ColorLabel, number>>;
|
||||
mediaFilter?: 'all' | 'photo' | 'video';
|
||||
onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void;
|
||||
showMediaFilter?: boolean;
|
||||
@@ -74,6 +81,10 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
likeCount = 0,
|
||||
favoriteCount = 0,
|
||||
ratedCount = 0,
|
||||
colorLabelsEnabled = false,
|
||||
activeColorFilters = [],
|
||||
onColorFilterChange,
|
||||
colorLabelCounts = {},
|
||||
mediaFilter = 'all',
|
||||
onMediaFilterChange,
|
||||
showMediaFilter = false
|
||||
@@ -241,6 +252,15 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
className="w-full"
|
||||
variant="compact"
|
||||
/>
|
||||
{/* Colour filter (#1044) */}
|
||||
{colorLabelsEnabled && onColorFilterChange && (
|
||||
<ColorLabelFilterChips
|
||||
className="mt-3"
|
||||
activeColors={activeColorFilters}
|
||||
onToggle={onColorFilterChange}
|
||||
counts={colorLabelCounts}
|
||||
/>
|
||||
)}
|
||||
</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 { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -26,7 +26,7 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||
import { api } from '../../config/api';
|
||||
import { Upload, Menu, Eye, EyeOff, Shield, X, Download } from 'lucide-react';
|
||||
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 { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
|
||||
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".
|
||||
// Clicking a filter toggles it, clicking "All" clears the set.
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
@@ -679,7 +694,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
|
||||
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
|
||||
// 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;
|
||||
}, [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)
|
||||
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
|
||||
|
||||
@@ -1090,6 +1123,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
likeCount={likeCount}
|
||||
favoriteCount={favoriteCount}
|
||||
ratedCount={ratedCount}
|
||||
colorLabelsEnabled={!!feedbackSettings?.allow_color_labels}
|
||||
activeColorFilters={activeColorFilters}
|
||||
onColorFilterChange={handleColorFilterToggle}
|
||||
colorLabelCounts={colorLabelCounts}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1237,6 +1274,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
mediaFilter={mediaFilter}
|
||||
onMediaFilterChange={setMediaFilter}
|
||||
showMediaFilter={showMediaFilter}
|
||||
colorLabelsEnabled={!!feedbackSettings?.allow_color_labels}
|
||||
activeColorFilters={activeColorFilters}
|
||||
onColorFilterChange={handleColorFilterToggle}
|
||||
colorLabelCounts={colorLabelCounts}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuthenticatedImage } from '../common';
|
||||
import { thumbnailUrlForTile } from './imageTiers';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { ColorLabelBadge } from './ColorLabelBadge';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
import type { Photo } from '../../types';
|
||||
|
||||
@@ -378,6 +379,11 @@ export const PhotoCard: React.FC<PhotoCardProps> = ({
|
||||
<>
|
||||
<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}
|
||||
|
||||
{/* 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 { Button, Input } from '../common';
|
||||
import type { FilterType, FeedbackFilterType } from './GalleryFilter';
|
||||
import { ColorLabelFilterChips } from './ColorLabelFilterChips';
|
||||
import type { ColorLabel } from '../../services/feedback.service';
|
||||
|
||||
interface PhotoCategory {
|
||||
id: number | string;
|
||||
@@ -40,6 +42,12 @@ interface PhotoFilterBarProps {
|
||||
mediaFilter?: 'all' | 'photo' | 'video';
|
||||
onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void;
|
||||
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> = ({
|
||||
@@ -59,7 +67,11 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
onFilterChange,
|
||||
mediaFilter = 'all',
|
||||
onMediaFilterChange,
|
||||
showMediaFilter = false
|
||||
showMediaFilter = false,
|
||||
colorLabelsEnabled = false,
|
||||
activeColorFilters = [],
|
||||
onColorFilterChange,
|
||||
colorLabelCounts = {}
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||
@@ -291,6 +303,16 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
</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
|
||||
chips are lg-only; mobile has its own block below), so hide
|
||||
the count below lg to keep the mobile layout unchanged. */}
|
||||
@@ -389,6 +411,16 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Colour filter (#1044), mobile/tablet */}
|
||||
{feedbackEnabled && colorLabelsEnabled && onColorFilterChange && (
|
||||
<ColorLabelFilterChips
|
||||
className="flex lg:hidden"
|
||||
activeColors={activeColorFilters}
|
||||
onToggle={onColorFilterChange}
|
||||
counts={colorLabelCounts}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,9 @@ import { useSavePhotoToDevice } from '../../hooks/useGallery';
|
||||
import { AuthenticatedImage } from '../common';
|
||||
import { PhotoFeedback } from './PhotoFeedback';
|
||||
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 { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
@@ -93,19 +95,25 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
allow_ratings?: boolean;
|
||||
allow_comments?: boolean;
|
||||
allow_reactions?: boolean;
|
||||
allow_color_labels?: boolean;
|
||||
keybind_mode?: KeybindMode;
|
||||
show_feedback_to_guests?: boolean;
|
||||
require_name_email?: boolean;
|
||||
} | null>(null);
|
||||
const [myLiked, setMyLiked] = useState<boolean>(false);
|
||||
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 [avgRating, setAvgRating] = useState<number>(0);
|
||||
const [totalRatings, setTotalRatings] = useState<number>(0);
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
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 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
|
||||
// in the lightbox (guest mode, simple mode, identity-modal-confirm path).
|
||||
const { modal: limitModal, handleError: handleLimitError } = useFeedbackLimitModal();
|
||||
@@ -224,6 +232,21 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
};
|
||||
}, [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(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
@@ -250,6 +273,30 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
handleDownload();
|
||||
}
|
||||
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;
|
||||
setMyLiked(!!data.my_feedback.liked);
|
||||
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);
|
||||
setAvgRating(Number(data.summary?.average_rating) || 0);
|
||||
setTotalRatings(Number(data.summary?.total_ratings) || 0);
|
||||
@@ -417,6 +466,72 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
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 = () => {
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
||||
resetZoom();
|
||||
@@ -927,6 +1042,27 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
</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
|
||||
own dedicated toolbar buttons above, so this panel toggle
|
||||
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);
|
||||
setTotalRatings(Number(fresh.summary?.total_ratings) || 0);
|
||||
} 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
|
||||
// the direct submit paths.
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
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
|
||||
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 'yet-another-react-lightbox/styles.css';
|
||||
import 'yet-another-react-lightbox/plugins/thumbnails.css';
|
||||
import { ColorLabelBadge } from '../ColorLabelBadge';
|
||||
import 'yet-another-react-lightbox/plugins/captions.css';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
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'}
|
||||
/>
|
||||
|
||||
{/* Colour label (#1044) — same badge every layout uses. */}
|
||||
<ColorLabelBadge colorLabel={photo.my_color_label} />
|
||||
|
||||
{/* Overlay Gradient */}
|
||||
<div className="gallery-premium-photo-overlay" />
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { AuthenticatedImage } from '../../../common';
|
||||
import { ColorLabelBadge } from '../../ColorLabelBadge';
|
||||
import type { Photo } from '../../../../types';
|
||||
|
||||
interface StoryPhotoCardProps {
|
||||
@@ -78,6 +79,9 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
|
||||
/>
|
||||
</a>
|
||||
|
||||
{/* Colour label (#1044) — same badge every layout uses. */}
|
||||
<ColorLabelBadge colorLabel={photo.my_color_label} />
|
||||
|
||||
{/* Overlay */}
|
||||
<div className="story-photo-card-overlay" />
|
||||
|
||||
|
||||
@@ -89,6 +89,15 @@ export interface EventSettings {
|
||||
event_require_expiration: boolean;
|
||||
event_default_require_password: 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;
|
||||
event_phone_field_enabled: boolean;
|
||||
}
|
||||
@@ -177,6 +186,13 @@ export function useSettingsState() {
|
||||
event_require_expiration: true,
|
||||
event_default_require_password: true,
|
||||
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,
|
||||
event_phone_field_enabled: false
|
||||
});
|
||||
@@ -285,6 +301,15 @@ export function useSettingsState() {
|
||||
event_require_expiration: toBoolean(settings.event_require_expiration, true),
|
||||
event_default_require_password: toBoolean(settings.event_default_require_password, true),
|
||||
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),
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import type { EventSettings } from '../hooks/useSettingsState';
|
||||
import { COLOR_LABEL_SWATCHES, COLOR_LABELS } from '../../../services/feedback.service';
|
||||
|
||||
interface EventsTabProps {
|
||||
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> = ({
|
||||
eventSettings,
|
||||
setEventSettings,
|
||||
@@ -188,6 +208,79 @@ export const EventsTab: React.FC<EventsTabProps> = ({
|
||||
</label>
|
||||
</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>
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
|
||||
@@ -1084,7 +1084,9 @@
|
||||
"privacyNote": "Personen werden automatisch innerhalb dieser Galerie erkannt. Es werden keine Daten an externe Dienste gesendet.",
|
||||
"inThisPhoto": "Auf diesem Foto:",
|
||||
"downloadThese": "Diese {{count}} herunterladen"
|
||||
}
|
||||
},
|
||||
"colorFilter": "Farbe",
|
||||
"filterByColor": "Nur {{color}} anzeigen"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Fotokategorien",
|
||||
@@ -1659,7 +1661,17 @@
|
||||
"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.",
|
||||
"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 – 1–5 Sterne, 6–9 Farben"
|
||||
},
|
||||
"imageSecurity": {
|
||||
"title": "Bildschutz",
|
||||
@@ -2881,7 +2893,15 @@
|
||||
"views": "Aufrufe",
|
||||
"downloads": "Downloads",
|
||||
"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": {
|
||||
"tabs": {
|
||||
@@ -2937,7 +2957,8 @@
|
||||
"comments": "Kommentare",
|
||||
"ratings": "Bewertungen",
|
||||
"reactions": "Reaktionen",
|
||||
"lastSeen": "Zuletzt gesehen"
|
||||
"lastSeen": "Zuletzt gesehen",
|
||||
"colorLabels": "Farbmarkierungen"
|
||||
},
|
||||
"view": "Details anzeigen",
|
||||
"export": "Exportieren",
|
||||
@@ -2951,7 +2972,8 @@
|
||||
"favorited": "Favorisiert",
|
||||
"rated": "Bewertet",
|
||||
"reacted": "Reagiert",
|
||||
"commented": "Kommentiert"
|
||||
"commented": "Kommentiert",
|
||||
"labeled": "Farbmarkierungen"
|
||||
},
|
||||
"inviteStatus": {
|
||||
"pending": "Ausstehend",
|
||||
@@ -3682,7 +3704,14 @@
|
||||
"enableRateLimiting": "Ratenbegrenzung aktivieren",
|
||||
"rateLimitingDesc": "Spam durch Begrenzung der Feedback-Häufigkeit verhindern",
|
||||
"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": "1–5 vergeben Sterne, 6–9 setzen Rot / Gelb / Grün / Blau"
|
||||
},
|
||||
"settingsUpdated": "Feedback-Einstellungen aktualisiert",
|
||||
"settingsUpdateError": "Einstellungen konnten nicht aktualisiert werden",
|
||||
@@ -3768,7 +3797,20 @@
|
||||
"viewAllFeedback": "Gesamtes Feedback & Einstellungen anzeigen",
|
||||
"exportShapeLabel": "Form",
|
||||
"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": {
|
||||
"feedbackFilters": "Feedback-Filter",
|
||||
@@ -3786,7 +3828,11 @@
|
||||
"twoStarsPlus": "2+ Sterne",
|
||||
"threeStarsPlus": "3+ 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": {
|
||||
"button": "Exportieren",
|
||||
@@ -3806,7 +3852,10 @@
|
||||
"copied": "In die Zwischenablage kopiert.",
|
||||
"copyFailed": "Zugriff auf die Zwischenablage blockiert. Bitte den Text markieren und manuell kopieren.",
|
||||
"download": "Als Datei herunterladen"
|
||||
}
|
||||
},
|
||||
"markSource": "XMP-Sterne & -Farbe aus",
|
||||
"markSourceClient": "Kundenauswahl",
|
||||
"markSourceMine": "Deine Markierungen"
|
||||
},
|
||||
"slideshow": {
|
||||
"adminTitle": "Live-Diashow",
|
||||
|
||||
@@ -625,7 +625,9 @@
|
||||
"privacyNote": "People are detected automatically inside this gallery. Nothing is sent to any external service.",
|
||||
"inThisPhoto": "In this photo:",
|
||||
"downloadThese": "Download these {{count}}"
|
||||
}
|
||||
},
|
||||
"colorFilter": "Color",
|
||||
"filterByColor": "Show only {{color}}"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Photo Categories",
|
||||
@@ -1263,7 +1265,17 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Image Protection",
|
||||
@@ -2453,7 +2465,15 @@
|
||||
"views": "Views",
|
||||
"downloads": "Downloads",
|
||||
"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": {
|
||||
"tabs": {
|
||||
@@ -2509,7 +2529,8 @@
|
||||
"comments": "Comments",
|
||||
"ratings": "Ratings",
|
||||
"reactions": "Reactions",
|
||||
"lastSeen": "Last seen"
|
||||
"lastSeen": "Last seen",
|
||||
"colorLabels": "Color labels"
|
||||
},
|
||||
"view": "View details",
|
||||
"export": "Export",
|
||||
@@ -2523,7 +2544,8 @@
|
||||
"favorited": "Favorited",
|
||||
"rated": "Rated",
|
||||
"reacted": "Reacted",
|
||||
"commented": "Commented"
|
||||
"commented": "Commented",
|
||||
"labeled": "Color labels"
|
||||
},
|
||||
"inviteStatus": {
|
||||
"pending": "Pending",
|
||||
@@ -3703,7 +3725,14 @@
|
||||
"enableRateLimiting": "Enable Rate Limiting",
|
||||
"rateLimitingDesc": "Prevent spam by limiting feedback frequency",
|
||||
"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",
|
||||
"settingsUpdateError": "Failed to update settings",
|
||||
@@ -3789,7 +3818,20 @@
|
||||
"viewAllFeedback": "View all feedback & settings",
|
||||
"exportShapeLabel": "Shape",
|
||||
"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": {
|
||||
"feedbackFilters": "Feedback Filters",
|
||||
@@ -3807,7 +3849,11 @@
|
||||
"twoStarsPlus": "2+ Stars",
|
||||
"threeStarsPlus": "3+ 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": {
|
||||
"title": "Welcome to PicPeak",
|
||||
@@ -4102,7 +4148,10 @@
|
||||
"copied": "Copied to clipboard.",
|
||||
"copyFailed": "Clipboard write blocked. Select the text and copy manually.",
|
||||
"download": "Download as file"
|
||||
}
|
||||
},
|
||||
"markSource": "XMP stars & colour from",
|
||||
"markSourceClient": "Client selections",
|
||||
"markSourceMine": "Your marks"
|
||||
},
|
||||
"photoSort": {
|
||||
"defaultSort": "Default Photo Sort",
|
||||
|
||||
@@ -318,7 +318,9 @@
|
||||
"anonymous": "Anónimo"
|
||||
},
|
||||
"rated": "Valorado",
|
||||
"commented": "Comentado"
|
||||
"commented": "Comentado",
|
||||
"colorFilter": "Color",
|
||||
"filterByColor": "Mostrar solo {{color}}"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categorías de fotos",
|
||||
@@ -923,7 +925,17 @@
|
||||
"expirationWarning": "Las galerías sin expiración permanecerán activas hasta ser archivadas manualmente",
|
||||
"saveSettings": "Guardar ajustes de eventos",
|
||||
"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": {
|
||||
"title": "Proteger Imagen",
|
||||
@@ -1569,7 +1581,23 @@
|
||||
"hideSelected": "Ocultar",
|
||||
"showSelected": "Mostrar",
|
||||
"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": {
|
||||
@@ -2418,8 +2446,28 @@
|
||||
"identityModeSimple": "Feedback simple",
|
||||
"identityModeSimpleDesc": "Anónimo, basado en dispositivo. Visitantes del mismo dispositivo comparten estado.",
|
||||
"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": {
|
||||
"feedbackFilters": "Filtros de feedback",
|
||||
@@ -2436,7 +2484,11 @@
|
||||
"hasFavorites": "Con favoritos",
|
||||
"hasComments": "Con comentarios",
|
||||
"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": {
|
||||
"title": "Acceso admin",
|
||||
@@ -2492,7 +2544,10 @@
|
||||
"error": "Error en la exportación: ",
|
||||
"exportSelected": "Exportar {{count}} seleccionadas",
|
||||
"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": {
|
||||
"defaultSort": "Orden por defecto",
|
||||
|
||||
@@ -338,7 +338,9 @@
|
||||
"photosSelected_one": "{{count}} photo sélectionnée",
|
||||
"photosSelected_other": "{{count}} photos sélectionnées",
|
||||
"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": {
|
||||
"title": "Catégories de photos",
|
||||
@@ -892,7 +894,17 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Protection des images",
|
||||
@@ -1659,7 +1671,15 @@
|
||||
"visibleSuccess": "Photos maintenant visibles pour les invités",
|
||||
"processingStatus": "Traitement en cours…",
|
||||
"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": {
|
||||
"tabs": {
|
||||
@@ -1715,7 +1735,8 @@
|
||||
"comments": "Commentaires",
|
||||
"ratings": "Notes",
|
||||
"reactions": "Réactions",
|
||||
"lastSeen": "Dernière visite"
|
||||
"lastSeen": "Dernière visite",
|
||||
"colorLabels": "Étiquettes de couleur"
|
||||
},
|
||||
"view": "Voir les détails",
|
||||
"export": "Exporter",
|
||||
@@ -1729,7 +1750,8 @@
|
||||
"favorited": "Favoris",
|
||||
"rated": "Notés",
|
||||
"reacted": "A réagi",
|
||||
"commented": "Commentés"
|
||||
"commented": "Commentés",
|
||||
"labeled": "Étiquettes de couleur"
|
||||
},
|
||||
"inviteStatus": {
|
||||
"pending": "En attente",
|
||||
@@ -2567,7 +2589,14 @@
|
||||
"enableRateLimiting": "Activer la limitation de débit",
|
||||
"rateLimitingDesc": "Empêcher le spam en limitant la fréquence des commentaires",
|
||||
"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",
|
||||
"settingsUpdateError": "Échec de la mise à jour des paramètres",
|
||||
@@ -2642,7 +2671,20 @@
|
||||
"onPhoto": "Sur la photo",
|
||||
"showAll_one": "Afficher tous les {{count}} commentaire 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": {
|
||||
"feedbackFilters": "Filtres de commentaires",
|
||||
@@ -2660,7 +2702,11 @@
|
||||
"twoStarsPlus": "2+ étoiles",
|
||||
"threeStarsPlus": "3+ é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": {
|
||||
"title": "Connexion administrateur",
|
||||
@@ -2716,7 +2762,10 @@
|
||||
"exportFiltered": "Exporter les photos filtrées",
|
||||
"hint": "Sélectionnez des photos ou appliquez des filtres pour exporter",
|
||||
"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": {
|
||||
"defaultSort": "Tri par défaut des photos",
|
||||
|
||||
@@ -338,7 +338,9 @@
|
||||
"socials": "Sociale media"
|
||||
},
|
||||
"photosCount_one": "{{count}} foto",
|
||||
"photosCount_other": "{{count}} foto's"
|
||||
"photosCount_other": "{{count}} foto's",
|
||||
"colorFilter": "Kleur",
|
||||
"filterByColor": "Alleen {{color}} tonen"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Fotocategorieen",
|
||||
@@ -888,7 +890,17 @@
|
||||
"showGalleryFilterBar": "Filterbalk in galerijen tonen",
|
||||
"showGalleryFilterBarHelp": "Toont de zoek-op-bestandsnaam en sorteerbediening boven rasterindelingsgalerijen. Uitschakelen voor een cleaner lay-out.",
|
||||
"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": {
|
||||
"title": "Afbeeldingsbeveiliging",
|
||||
@@ -1648,7 +1660,15 @@
|
||||
"visibleSuccess": "Foto's nu zichtbaar voor gasten",
|
||||
"processingStatus": "Verwerken…",
|
||||
"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": {
|
||||
"tabs": {
|
||||
@@ -1704,7 +1724,8 @@
|
||||
"comments": "Opmerkingen",
|
||||
"ratings": "Beoordelingen",
|
||||
"reactions": "Reacties",
|
||||
"lastSeen": "Laatste bezoek"
|
||||
"lastSeen": "Laatste bezoek",
|
||||
"colorLabels": "Kleurlabels"
|
||||
},
|
||||
"view": "Details bekijken",
|
||||
"export": "Exporteren",
|
||||
@@ -1718,7 +1739,8 @@
|
||||
"favorited": "Favoriet",
|
||||
"rated": "Beoordeeld",
|
||||
"reacted": "Gereageerd",
|
||||
"commented": "Becommentarieerd"
|
||||
"commented": "Becommentarieerd",
|
||||
"labeled": "Kleurlabels"
|
||||
},
|
||||
"inviteStatus": {
|
||||
"pending": "In behandeling",
|
||||
@@ -2556,7 +2578,14 @@
|
||||
"enableRateLimiting": "Snelheidsbeperking inschakelen",
|
||||
"rateLimitingDesc": "Voorkom spam door de feedbackfrequentie te beperken",
|
||||
"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",
|
||||
"settingsUpdateError": "Bijwerken instellingen mislukt",
|
||||
@@ -2631,7 +2660,20 @@
|
||||
"onPhoto": "Op foto",
|
||||
"showAll_one": "Alle {{count}} openstaande opmerking 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": {
|
||||
"feedbackFilters": "Feedbackfilters",
|
||||
@@ -2649,7 +2691,11 @@
|
||||
"twoStarsPlus": "2+ sterren",
|
||||
"threeStarsPlus": "3+ 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": {
|
||||
"button": "Exporteren",
|
||||
@@ -2658,7 +2704,10 @@
|
||||
"exportFiltered": "Gefilterde foto's exporteren",
|
||||
"hint": "Selecteer foto's of pas filters toe om te 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": {
|
||||
"title": "Beheerder login",
|
||||
|
||||
@@ -346,7 +346,9 @@
|
||||
},
|
||||
"photosCount_many": "{{count}} fotos",
|
||||
"photosCount_one": "{{count}} foto",
|
||||
"photosCount_other": "{{count}} fotos"
|
||||
"photosCount_other": "{{count}} fotos",
|
||||
"colorFilter": "Cor",
|
||||
"filterByColor": "Mostrar apenas {{color}}"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categorias de Fotos",
|
||||
@@ -905,7 +907,17 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Proteção de Imagem",
|
||||
@@ -1669,7 +1681,15 @@
|
||||
"visibleSuccess": "Fotos agora visíveis aos visitantes",
|
||||
"processingStatus": "A processar…",
|
||||
"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": {
|
||||
"tabs": {
|
||||
@@ -1729,7 +1749,8 @@
|
||||
"comments": "Comentários",
|
||||
"ratings": "Avaliações",
|
||||
"reactions": "Reações",
|
||||
"lastSeen": "Última visita"
|
||||
"lastSeen": "Última visita",
|
||||
"colorLabels": "Etiquetas de cor"
|
||||
},
|
||||
"view": "Ver detalhes",
|
||||
"export": "Exportar",
|
||||
@@ -1743,7 +1764,8 @@
|
||||
"favorited": "Favorito",
|
||||
"rated": "Avaliado",
|
||||
"reacted": "Reagiu",
|
||||
"commented": "Comentado"
|
||||
"commented": "Comentado",
|
||||
"labeled": "Etiquetas de cor"
|
||||
},
|
||||
"inviteStatus": {
|
||||
"pending": "Pendente",
|
||||
@@ -2581,7 +2603,14 @@
|
||||
"enableRateLimiting": "Ativar limitação de taxa",
|
||||
"rateLimitingDesc": "Previne spam limitando a frequência de feedback",
|
||||
"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",
|
||||
"settingsUpdateError": "Falha ao atualizar definições",
|
||||
@@ -2661,7 +2690,20 @@
|
||||
"showAll_many": "Mostrar todos os {{count}} comentários pendentes",
|
||||
"showAll_one": "Mostrar o {{count}} comentário pendente",
|
||||
"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": {
|
||||
"feedbackFilters": "Filtros de Feedback",
|
||||
@@ -2679,7 +2721,11 @@
|
||||
"twoStarsPlus": "2+ estrelas",
|
||||
"threeStarsPlus": "3+ 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": {
|
||||
"title": "Login Administrativo",
|
||||
@@ -2736,7 +2782,10 @@
|
||||
"hint": "Selecione fotos ou aplique filtros para exportar",
|
||||
"exportSelected_many": "Exportar {{count}} selecionados",
|
||||
"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": {
|
||||
"defaultSort": "Ordenação padrão das fotos",
|
||||
|
||||
@@ -354,7 +354,9 @@
|
||||
"photosCount_few": "{{count}} фото",
|
||||
"photosCount_many": "{{count}} фото",
|
||||
"photosCount_one": "{{count}} фото",
|
||||
"photosCount_other": "{{count}} фото"
|
||||
"photosCount_other": "{{count}} фото",
|
||||
"colorFilter": "Цвет",
|
||||
"filterByColor": "Показать только «{{color}}»"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Категории фото",
|
||||
@@ -911,7 +913,17 @@
|
||||
"showGalleryFilterBar": "Показывать панель фильтров в галереях",
|
||||
"showGalleryFilterBarHelp": "Отображает поиск по имени файла и элементы управления сортировкой над галереями с сеткой. Отключите для более чистого интерфейса.",
|
||||
"enablePhoneField": "Включить поле номера телефона",
|
||||
"enablePhoneFieldHelp": "Добавляет необязательное поле ввода номера телефона в форму события. Полезно для автоматизаций, таких как доставка в WhatsApp через n8n. Всегда необязательно, даже если включено."
|
||||
"enablePhoneFieldHelp": "Добавляет необязательное поле ввода номера телефона в форму события. Полезно для автоматизаций, таких как доставка в WhatsApp через n8n. Всегда необязательно, даже если включено.",
|
||||
"feedbackTypeDefaultsHelp": "С какими типами отзывов создаются новые галереи. Существующие галереи не меняются — каждую можно настроить отдельно.",
|
||||
"defaultAllowRatings": "Оценка звёздами",
|
||||
"defaultAllowLikes": "Лайки",
|
||||
"defaultAllowFavorites": "Избранное",
|
||||
"defaultAllowComments": "Комментарии",
|
||||
"defaultAllowReactions": "Эмодзи-реакции",
|
||||
"defaultAllowColorLabels": "Цветовые метки",
|
||||
"defaultKeybindMode": "Горячие клавиши просмотра по умолчанию",
|
||||
"keybindColors": "Только цвета — 1 зелёный, 2 жёлтый, 3 красный",
|
||||
"keybindLightroom": "Lightroom — 1–5 звёзды, 6–9 цвета"
|
||||
},
|
||||
"imageSecurity": {
|
||||
"title": "Защита изображений",
|
||||
@@ -1690,7 +1702,15 @@
|
||||
"visibleSuccess": "Фотографии теперь видны гостям",
|
||||
"processingStatus": "Обработка…",
|
||||
"processingFailed": "Ошибка",
|
||||
"retryQueued": "Повтор в очереди"
|
||||
"retryQueued": "Повтор в очереди",
|
||||
"myMarks": "Ваши отметки",
|
||||
"myMarksHelp": "Видны только вам. Они никогда не появляются в клиентской галерее и экспортируются в Lightroom через XMP.",
|
||||
"myRating": "Ваша оценка",
|
||||
"clearRating": "Убрать вашу оценку",
|
||||
"rateStars": "Оценить на {{count}}",
|
||||
"markError": "Не удалось сохранить отметку",
|
||||
"yourMarkColor": "Ваша отметка: {{color}}",
|
||||
"yourMarkRating": "Ваша оценка: {{count}}"
|
||||
},
|
||||
"events": {
|
||||
"tabs": {
|
||||
@@ -1754,7 +1774,8 @@
|
||||
"comments": "Комментарии",
|
||||
"ratings": "Оценки",
|
||||
"reactions": "Реакции",
|
||||
"lastSeen": "Последний визит"
|
||||
"lastSeen": "Последний визит",
|
||||
"colorLabels": "Цветовые метки"
|
||||
},
|
||||
"view": "Подробнее",
|
||||
"export": "Экспортировать",
|
||||
@@ -1768,7 +1789,8 @@
|
||||
"favorited": "В избранном",
|
||||
"rated": "Оценено",
|
||||
"reacted": "Отреагировал",
|
||||
"commented": "Прокомментировано"
|
||||
"commented": "Прокомментировано",
|
||||
"labeled": "Цветовые метки"
|
||||
},
|
||||
"inviteStatus": {
|
||||
"pending": "Ожидает",
|
||||
@@ -2606,7 +2628,14 @@
|
||||
"enableRateLimiting": "Включить ограничение частоты",
|
||||
"rateLimitingDesc": "Предотвращает спам, ограничивая частоту отзывов",
|
||||
"timeWindow": "Временное окно (минуты)",
|
||||
"maxRequests": "Макс. запросов"
|
||||
"maxRequests": "Макс. запросов",
|
||||
"colorLabels": "Цветовые метки",
|
||||
"colorLabelsDesc": "Одна метка на гостя и фотографию, в цветовой схеме Lightroom — выбор переносится через XMP",
|
||||
"keybindMode": "Горячие клавиши",
|
||||
"keybindColors": "Только цвета (самый простой вариант)",
|
||||
"keybindColorsDesc": "1 — зелёный (1-й выбор), 2 — жёлтый (2-й выбор), 3 — красный (отклонено)",
|
||||
"keybindLightroom": "Как в Lightroom",
|
||||
"keybindLightroomDesc": "1–5 ставят звёзды, 6–9 — красный / жёлтый / зелёный / синий"
|
||||
},
|
||||
"settingsUpdated": "Настройки отзывов обновлены",
|
||||
"settingsUpdateError": "Не удалось обновить настройки",
|
||||
@@ -2691,7 +2720,20 @@
|
||||
"showAll_many": "Показать все {{count}} ожидающих комментариев",
|
||||
"showAll_one": "Показать {{count}} ожидающий комментарий",
|
||||
"showAll_other": "Показать все {{count}} ожидающих комментариев",
|
||||
"viewAllFeedback": "Просмотреть все отзывы и настройки"
|
||||
"viewAllFeedback": "Просмотреть все отзывы и настройки",
|
||||
"colorLabels": {
|
||||
"red": "Красный",
|
||||
"yellow": "Жёлтый",
|
||||
"green": "Зелёный",
|
||||
"blue": "Синий",
|
||||
"purple": "Фиолетовый"
|
||||
},
|
||||
"colorLabel": "цветовая метка",
|
||||
"colorLabelsTitle": "Цветовые метки",
|
||||
"colorLabelError": "Не удалось обновить цветовую метку",
|
||||
"removeColorLabel": "Убрать метку «{{color}}»",
|
||||
"setColorLabel": "Отметить как «{{color}}»",
|
||||
"markedAs": "Отмечено как «{{color}}»"
|
||||
},
|
||||
"filter": {
|
||||
"feedbackFilters": "Фильтры отзывов",
|
||||
@@ -2709,7 +2751,11 @@
|
||||
"twoStarsPlus": "2+ звезды",
|
||||
"threeStarsPlus": "3+ звезды",
|
||||
"fourStarsPlus": "4+ звезды",
|
||||
"fiveStarsOnly": "Только 5 звёзд"
|
||||
"fiveStarsOnly": "Только 5 звёзд",
|
||||
"colorLabels": "Цветовые метки",
|
||||
"showOnlyColor": "Показать только «{{color}}»",
|
||||
"myColorLabels": "Ваши отметки",
|
||||
"showOnlyMyColor": "Показать только мои отметки «{{color}}»"
|
||||
},
|
||||
"adminLogin": {
|
||||
"title": "Вход для администратора",
|
||||
@@ -2767,7 +2813,10 @@
|
||||
"exportSelected_few": "Экспортировать {{count}} выбранных",
|
||||
"exportSelected_many": "Экспортировать {{count}} выбранных",
|
||||
"exportSelected_one": "Экспортировать {{count}} выбранный",
|
||||
"exportSelected_other": "Экспортировать {{count}} выбранных"
|
||||
"exportSelected_other": "Экспортировать {{count}} выбранных",
|
||||
"markSource": "Звёзды и цвет для XMP из",
|
||||
"markSourceClient": "Выбор клиента",
|
||||
"markSourceMine": "Ваши отметки"
|
||||
},
|
||||
"photoSort": {
|
||||
"defaultSort": "Сортировка фото по умолчанию",
|
||||
|
||||
@@ -338,7 +338,9 @@
|
||||
"photosSelected_one": "Izbrana {{count}} fotografija",
|
||||
"photosSelected_other": "Izbranih {{count}} fotografij",
|
||||
"downloadSelected_one": "Prenesi {{count}} fotografijo",
|
||||
"downloadSelected_other": "Prenesi {{count}} fotografij"
|
||||
"downloadSelected_other": "Prenesi {{count}} fotografij",
|
||||
"colorFilter": "Barva",
|
||||
"filterByColor": "Prikaži samo {{color}}"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Kategorije fotografij",
|
||||
@@ -892,7 +894,17 @@
|
||||
"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.",
|
||||
"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 – 1–5 zvezdice, 6–9 barve"
|
||||
},
|
||||
"imageSecurity": {
|
||||
"title": "Zaščita slik",
|
||||
@@ -1648,7 +1660,15 @@
|
||||
"visibleSuccess": "Fotografije so zdaj vidne gostom",
|
||||
"processingStatus": "Obdelava…",
|
||||
"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": {
|
||||
"tabs": {
|
||||
@@ -1704,7 +1724,8 @@
|
||||
"comments": "Komentarji",
|
||||
"ratings": "Ocene",
|
||||
"reactions": "Odzivi",
|
||||
"lastSeen": "Nazadnje viden"
|
||||
"lastSeen": "Nazadnje viden",
|
||||
"colorLabels": "Barvne oznake"
|
||||
},
|
||||
"view": "Ogled podrobnosti",
|
||||
"export": "Izvozi",
|
||||
@@ -1718,7 +1739,8 @@
|
||||
"favorited": "Dodano med priljubljene",
|
||||
"rated": "Ocenjeno",
|
||||
"reacted": "Odzval se",
|
||||
"commented": "Komentirano"
|
||||
"commented": "Komentirano",
|
||||
"labeled": "Barvne oznake"
|
||||
},
|
||||
"inviteStatus": {
|
||||
"pending": "Čaka",
|
||||
@@ -2556,7 +2578,14 @@
|
||||
"enableRateLimiting": "Omogoči omejevanje hitrosti",
|
||||
"rateLimitingDesc": "Preprečite spam z omejitvijo pogostosti povratnih informacij",
|
||||
"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": "1–5 določijo zvezdice, 6–9 rdečo / rumeno / zeleno / modro"
|
||||
},
|
||||
"settingsUpdated": "Nastavitve povratnih informacij posodobljene",
|
||||
"settingsUpdateError": "Nastavitev ni bilo mogoče posodobiti",
|
||||
@@ -2631,7 +2660,20 @@
|
||||
"onPhoto": "Na fotografiji",
|
||||
"showAll_one": "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": {
|
||||
"feedbackFilters": "Filtri povratnih informacij",
|
||||
@@ -2649,7 +2691,11 @@
|
||||
"twoStarsPlus": "2+ zvezdici",
|
||||
"threeStarsPlus": "3+ 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": {
|
||||
"title": "Prijava administratorja",
|
||||
@@ -2705,7 +2751,10 @@
|
||||
"exportFiltered": "Izvozi filtrirane fotografije",
|
||||
"hint": "Izberite fotografije ali uporabite filtre za izvoz",
|
||||
"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": {
|
||||
"defaultSort": "Privzeto razvrščanje fotografij",
|
||||
|
||||
@@ -65,6 +65,8 @@ interface FormData {
|
||||
allow_comments: boolean;
|
||||
allow_favorites: boolean;
|
||||
allow_reactions: boolean;
|
||||
allow_color_labels: boolean;
|
||||
keybind_mode: 'colors' | 'lightroom';
|
||||
require_name_email: boolean;
|
||||
moderate_comments: boolean;
|
||||
show_feedback_to_guests: boolean;
|
||||
@@ -134,6 +136,8 @@ export const CreateEventPage: React.FC = () => {
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
allow_reactions: true,
|
||||
allow_color_labels: false,
|
||||
keybind_mode: 'colors',
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true,
|
||||
@@ -270,11 +274,12 @@ export const CreateEventPage: React.FC = () => {
|
||||
}));
|
||||
}, [publicSettings]);
|
||||
|
||||
// Honour the global "Enable Guest Feedback by default" admin setting (#520).
|
||||
// Same one-shot apply pattern as require_password above — only seeds the
|
||||
// master toggle. The sub-toggles (likes / ratings / comments) keep their
|
||||
// hard-coded true defaults so a flipped master immediately gives sensible
|
||||
// behaviour without a second admin setting to manage.
|
||||
// Honour the global guest-feedback defaults (#520 for the master toggle,
|
||||
// #1044 for the per-type ones). Same one-shot apply pattern as
|
||||
// require_password above. This form POSTs every sub-toggle explicitly, so
|
||||
// seeding them here is what makes the Settings > Events defaults actually
|
||||
// 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);
|
||||
useEffect(() => {
|
||||
if (feedbackEnabledDefaultApplied.current) return;
|
||||
@@ -284,7 +289,14 @@ export const CreateEventPage: React.FC = () => {
|
||||
...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]);
|
||||
@@ -485,6 +497,8 @@ export const CreateEventPage: React.FC = () => {
|
||||
allow_comments: feedbackSettings.allow_comments,
|
||||
allow_favorites: feedbackSettings.allow_favorites,
|
||||
allow_reactions: feedbackSettings.allow_reactions,
|
||||
allow_color_labels: feedbackSettings.allow_color_labels,
|
||||
keybind_mode: feedbackSettings.keybind_mode,
|
||||
require_name_email: feedbackSettings.require_name_email,
|
||||
moderate_comments: feedbackSettings.moderate_comments,
|
||||
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
|
||||
|
||||
@@ -46,6 +46,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
allow_reactions: true,
|
||||
allow_color_labels: false,
|
||||
keybind_mode: 'colors',
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true,
|
||||
@@ -92,6 +94,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
hasLikes: false,
|
||||
hasFavorites: false,
|
||||
hasComments: false,
|
||||
colorLabels: [],
|
||||
myColorLabels: [],
|
||||
logic: 'AND'
|
||||
});
|
||||
|
||||
@@ -133,6 +137,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
hasFavorites: feedbackFilters.hasFavorites || undefined,
|
||||
hasComments: feedbackFilters.hasComments || undefined,
|
||||
minRating: feedbackFilters.minRating ?? undefined,
|
||||
colorLabels: feedbackFilters.colorLabels?.length ? feedbackFilters.colorLabels : undefined,
|
||||
myColorLabels: feedbackFilters.myColorLabels?.length ? feedbackFilters.myColorLabels : undefined,
|
||||
logic: feedbackFilters.logic,
|
||||
}), [photoFilters, feedbackFilters]);
|
||||
|
||||
|
||||
@@ -7,6 +7,55 @@ export type IdentityMode = 'simple' | 'guest';
|
||||
export const REACTION_EMOJIS = ['❤️', '😂', '😍', '👏', '🎉'] as const;
|
||||
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 {
|
||||
feedback_enabled: boolean;
|
||||
allow_ratings: boolean;
|
||||
@@ -14,6 +63,9 @@ export interface FeedbackSettings {
|
||||
allow_comments: boolean;
|
||||
allow_favorites: boolean;
|
||||
allow_reactions: boolean;
|
||||
allow_color_labels: boolean;
|
||||
/** Which lightbox shortcut scheme this gallery uses (#1044). */
|
||||
keybind_mode?: KeybindMode;
|
||||
require_name_email: boolean;
|
||||
moderate_comments: boolean;
|
||||
show_feedback_to_guests: boolean;
|
||||
@@ -34,11 +86,12 @@ export interface PhotoFeedback {
|
||||
id: number;
|
||||
photo_id: number;
|
||||
event_id: number;
|
||||
feedback_type: 'rating' | 'like' | 'comment' | 'favorite' | 'reaction';
|
||||
feedback_type: 'rating' | 'like' | 'comment' | 'favorite' | 'reaction' | 'color_label';
|
||||
rating?: number;
|
||||
comment_text?: string;
|
||||
comment?: string;
|
||||
reaction?: string;
|
||||
color_label?: ColorLabel | null;
|
||||
guest_name?: string;
|
||||
guest_email?: string;
|
||||
is_approved: boolean;
|
||||
@@ -57,6 +110,7 @@ export interface FeedbackSummary {
|
||||
like_count: number;
|
||||
favorite_count: number;
|
||||
reaction_count?: number;
|
||||
color_label_count?: number;
|
||||
comment_count: number;
|
||||
}
|
||||
|
||||
@@ -65,6 +119,7 @@ export interface MyFeedback {
|
||||
liked: boolean;
|
||||
favorited: boolean;
|
||||
reaction?: string | null;
|
||||
color_label?: ColorLabel | null;
|
||||
}
|
||||
|
||||
export interface FeedbackResponse {
|
||||
@@ -72,6 +127,8 @@ export interface FeedbackResponse {
|
||||
summary: FeedbackSummary;
|
||||
/** Per-emoji tallies for the reaction bar (#839), e.g. { '❤️': 3 }. */
|
||||
reactions?: Record<string, number>;
|
||||
/** Per-colour tallies (#1044), e.g. { green: 3 }. */
|
||||
color_labels?: Partial<Record<ColorLabel, number>>;
|
||||
my_feedback: MyFeedback;
|
||||
pagination?: {
|
||||
page: number;
|
||||
@@ -210,10 +267,11 @@ class FeedbackService {
|
||||
}
|
||||
|
||||
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;
|
||||
comment_text?: string;
|
||||
reaction?: string;
|
||||
color_label?: ColorLabel;
|
||||
guest_name?: string;
|
||||
guest_email?: string;
|
||||
}) {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface AdminGuestStats {
|
||||
comments: number;
|
||||
ratings: number;
|
||||
reactions: number;
|
||||
color_labels: number;
|
||||
distinct_photos: number;
|
||||
}
|
||||
|
||||
@@ -47,6 +48,7 @@ export interface AdminGuestSelections {
|
||||
rated: Array<{ photo: AdminGuestPhoto; rating: number }>;
|
||||
commented: Array<{ photo: AdminGuestPhoto; comment: string; created_at: string }>;
|
||||
reacted: Array<{ photo: AdminGuestPhoto; reaction: string }>;
|
||||
labeled: Array<{ photo: AdminGuestPhoto; color_label: string }>;
|
||||
}
|
||||
|
||||
export interface AdminGuestDetail {
|
||||
|
||||
@@ -24,6 +24,16 @@ export interface AdminPhoto {
|
||||
comment_count?: number;
|
||||
like_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 {
|
||||
@@ -37,10 +47,27 @@ export interface PhotoFilters {
|
||||
hasFavorites?: boolean;
|
||||
hasComments?: boolean;
|
||||
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';
|
||||
}
|
||||
|
||||
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[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -59,6 +86,12 @@ class PhotosService {
|
||||
if (filters.minRating !== undefined && filters.minRating !== null) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -245,6 +278,12 @@ class PhotosService {
|
||||
if (filters.hasLikes) params.append('has_likes', 'true');
|
||||
if (filters.hasFavorites) params.append('has_favorites', '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.logic) params.append('logic', filters.logic);
|
||||
if (filters.sort) params.append('sort', filters.sort);
|
||||
@@ -339,6 +378,10 @@ export interface FeedbackFilters {
|
||||
hasFavorites?: boolean;
|
||||
minFavorites?: number;
|
||||
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;
|
||||
logic?: 'AND' | 'OR';
|
||||
sort?: 'rating' | 'likes' | 'favorites' | 'date' | 'filename';
|
||||
@@ -353,6 +396,11 @@ export interface FilterSummary {
|
||||
withLikes: number;
|
||||
withFavorites: 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 {
|
||||
|
||||
@@ -95,6 +95,14 @@ export interface PublicSettings {
|
||||
event_require_expiration?: boolean;
|
||||
event_default_require_password?: 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;
|
||||
event_phone_field_enabled?: boolean;
|
||||
// SEO meta tags (consumed by RobotsMetaTags)
|
||||
|
||||
@@ -208,6 +208,12 @@ export interface Photo {
|
||||
// Used to seed the lifted likedPhotoIds Set in grid layouts on mount.
|
||||
is_liked?: boolean;
|
||||
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).
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user