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

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

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

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

Merged with admin privileges: bypass-size-gate is a required check that fails on size alone for review-bypass authors and never re-evaluates on review, which is its designed behaviour once a maintainer has approved.
This commit is contained in:
Luca
2026-08-23 11:15:01 +02:00
committed by GitHub
parent 7b77bbf243
commit e2844d1909
60 changed files with 3912 additions and 182 deletions
@@ -0,0 +1,129 @@
import { describe, it, expect } from 'vitest';
import {
resolveFeedbackKey,
colorShortcutHints,
isTypingTarget,
} from '../feedbackKeybinds';
/**
* Proofing shortcuts (#1044). Two schemes share the same digit keys, so the
* mapping is the whole feature — and the guards matter as much as the map:
* a bare digit must not relabel a photo while someone is typing into the
* filename search, and Cmd+1 must stay a browser tab switch.
*/
const key = (k: string, init: Partial<KeyboardEventInit> = {}) =>
new KeyboardEvent('keydown', { key: k, ...init });
const ALL_ON = { allowColorLabels: true, allowRatings: true } as const;
describe('resolveFeedbackKey — colours-only scheme', () => {
const opts = { mode: 'colors' as const, ...ALL_ON };
it('maps 1/2/3 to 1st choice / 2nd choice / rejected', () => {
expect(resolveFeedbackKey(key('1'), opts)).toEqual({ type: 'color', color: 'green' });
expect(resolveFeedbackKey(key('2'), opts)).toEqual({ type: 'color', color: 'yellow' });
expect(resolveFeedbackKey(key('3'), opts)).toEqual({ type: 'color', color: 'red' });
});
it('leaves 4-9 unbound even when ratings are enabled', () => {
for (const k of ['4', '5', '6', '7', '8', '9']) {
expect(resolveFeedbackKey(key(k), opts)).toBeNull();
}
});
it('clears the colour with 0', () => {
expect(resolveFeedbackKey(key('0'), opts)).toEqual({ type: 'clear' });
});
});
describe('resolveFeedbackKey — Lightroom scheme', () => {
const opts = { mode: 'lightroom' as const, ...ALL_ON };
it('maps 1-5 to star ratings', () => {
for (const n of [1, 2, 3, 4, 5]) {
expect(resolveFeedbackKey(key(String(n)), opts)).toEqual({ type: 'rating', value: n });
}
});
it('maps 6-9 to red / yellow / green / blue', () => {
expect(resolveFeedbackKey(key('6'), opts)).toEqual({ type: 'color', color: 'red' });
expect(resolveFeedbackKey(key('7'), opts)).toEqual({ type: 'color', color: 'yellow' });
expect(resolveFeedbackKey(key('8'), opts)).toEqual({ type: 'color', color: 'green' });
expect(resolveFeedbackKey(key('9'), opts)).toEqual({ type: 'color', color: 'blue' });
});
it('clears the rating with 0, matching Lightroom', () => {
expect(resolveFeedbackKey(key('0'), opts)).toEqual({ type: 'rating', value: 0 });
});
});
describe('resolveFeedbackKey — gating', () => {
it('ignores colour keys when colour labels are off', () => {
expect(resolveFeedbackKey(key('1'), {
mode: 'colors', allowColorLabels: false, allowRatings: true,
})).toBeNull();
});
it('ignores star keys when ratings are off', () => {
expect(resolveFeedbackKey(key('4'), {
mode: 'lightroom', allowColorLabels: true, allowRatings: false,
})).toBeNull();
// …but the colour keys of the same scheme still work.
expect(resolveFeedbackKey(key('8'), {
mode: 'lightroom', allowColorLabels: true, allowRatings: false,
})).toEqual({ type: 'color', color: 'green' });
});
it('falls back to the colours scheme for an unknown mode', () => {
expect(resolveFeedbackKey(key('1'), {
mode: 'nonsense' as unknown as 'colors', ...ALL_ON,
})).toEqual({ type: 'color', color: 'green' });
});
it('never fires with a modifier held — Cmd+1 stays a tab switch', () => {
const opts = { mode: 'colors' as const, ...ALL_ON };
expect(resolveFeedbackKey(key('1', { metaKey: true }), opts)).toBeNull();
expect(resolveFeedbackKey(key('1', { ctrlKey: true }), opts)).toBeNull();
expect(resolveFeedbackKey(key('1', { altKey: true }), opts)).toBeNull();
});
it('never fires while the user is typing', () => {
const opts = { mode: 'colors' as const, ...ALL_ON };
for (const tag of ['input', 'textarea', 'select']) {
const element = document.createElement(tag);
const event = key('1');
Object.defineProperty(event, 'target', { value: element });
expect(resolveFeedbackKey(event, opts)).toBeNull();
}
const editable = document.createElement('div');
editable.contentEditable = 'true';
// jsdom doesn't derive isContentEditable from the attribute.
Object.defineProperty(editable, 'isContentEditable', { value: true });
const event = key('1');
Object.defineProperty(event, 'target', { value: editable });
expect(resolveFeedbackKey(event, opts)).toBeNull();
});
});
describe('isTypingTarget', () => {
it('is false for null and for ordinary elements', () => {
expect(isTypingTarget(null)).toBe(false);
expect(isTypingTarget(document.createElement('div'))).toBe(false);
});
});
describe('colorShortcutHints', () => {
it('reports the keys the active scheme actually binds', () => {
expect(colorShortcutHints('colors')).toEqual({ green: '1', yellow: '2', red: '3' });
expect(colorShortcutHints('lightroom')).toEqual({
red: '6', yellow: '7', green: '8', blue: '9',
});
});
it('never claims a shortcut for purple — Lightroom has none either', () => {
expect(colorShortcutHints('colors').purple).toBeUndefined();
expect(colorShortcutHints('lightroom').purple).toBeUndefined();
});
});
+85
View File
@@ -0,0 +1,85 @@
import { KEYBIND_SCHEMES, type ColorLabel, type KeybindMode } from '../services/feedback.service';
/**
* Lightbox keyboard shortcuts for proofing (#1044).
*
* Shared by the gallery lightbox and the admin photo viewer — two components
* with independent key handlers that would otherwise drift the moment either
* one gained a shortcut.
*/
export type FeedbackKeyAction =
| { type: 'color'; color: ColorLabel }
| { type: 'rating'; value: number }
| { type: 'clear' };
interface ResolveOptions {
mode: KeybindMode;
allowColorLabels: boolean;
allowRatings: boolean;
}
/**
* True when the event came from somewhere a digit is real input — a search
* box, a comment field, a contenteditable. Without this, typing "2024" into
* the filename search would relabel the open photo.
*/
export function isTypingTarget(target: EventTarget | null): boolean {
const element = target as HTMLElement | null;
if (!element || typeof element.tagName !== 'string') return false;
const tag = element.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
return element.isContentEditable === true;
}
/**
* Map a keydown to a proofing action, or null when the key isn't bound in the
* active scheme (the caller's other shortcuts then get their turn).
*
* Modified keys are never bound: Ctrl+1 / Cmd+1 switch browser tabs, and Alt
* combinations are OS shortcuts.
*/
export function resolveFeedbackKey(
event: KeyboardEvent,
{ mode, allowColorLabels, allowRatings }: ResolveOptions
): FeedbackKeyAction | null {
if (event.ctrlKey || event.metaKey || event.altKey) return null;
if (isTypingTarget(event.target)) return null;
const scheme = KEYBIND_SCHEMES[mode] || KEYBIND_SCHEMES.colors;
const key = event.key;
if (allowColorLabels) {
const color = scheme.colors[key];
if (color) return { type: 'color', color };
}
if (allowRatings) {
const rating = scheme.ratings[key];
if (rating !== undefined) return { type: 'rating', value: rating };
}
// '0' clears whichever value the scheme is primarily about: the star rating
// in Lightroom mode (matching Lightroom itself), the colour label in
// colour-only mode, where there are no stars to clear.
if (key === '0') {
if (mode === 'lightroom' && allowRatings) return { type: 'rating', value: 0 };
if (allowColorLabels) return { type: 'clear' };
if (allowRatings) return { type: 'rating', value: 0 };
}
return null;
}
/**
* Which key sets which colour in the active scheme, for the hints rendered on
* the swatches — e.g. { green: '1', yellow: '2', red: '3' }.
*/
export function colorShortcutHints(mode: KeybindMode): Partial<Record<ColorLabel, string>> {
const scheme = KEYBIND_SCHEMES[mode] || KEYBIND_SCHEMES.colors;
const hints: Partial<Record<ColorLabel, string>> = {};
for (const [key, color] of Object.entries(scheme.colors)) {
hints[color] = key;
}
return hints;
}