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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user