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
@@ -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