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
@@ -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">15</span>
</div>
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label={t('feedback.colorLabelsTitle', 'Color labels')}>
{COLOR_LABELS.map((color) => {
const swatch = COLOR_LABEL_SWATCHES[color];
const isMine = currentMark.color_label === color;
const shortcut = colorShortcutHints(keybindMode)[color];
const name = t(`feedback.colorLabels.${color}`, color);
return (
<button
key={color}
type="button"
onClick={() => toggleMarkColor(color)}
aria-pressed={isMine}
// Colour alone can't carry which swatch this is.
aria-label={isMine
? t('feedback.removeColorLabel', 'Remove {{color}} label', { color: name })
: t('feedback.setColorLabel', 'Mark as {{color}}', { color: name })}
title={shortcut ? `${name} (${shortcut})` : name}
className={`flex items-center gap-1 pl-1.5 pr-2 py-1 rounded-full text-xs transition-all ${
isMine ? 'bg-white/15 ring-1 ring-white/60' : 'bg-white/5 hover:bg-white/10'
}`}
>
<span
className="w-3.5 h-3.5 rounded-full border shrink-0"
style={{ backgroundColor: swatch.fill, borderColor: swatch.ring }}
aria-hidden="true"
/>
{shortcut && <span className="text-neutral-400">{shortcut}</span>}
</button>
);
})}
</div>
</div>
{/* Feedback Section */}
{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">