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">
@@ -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
+58 -9
View File
@@ -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 15 Sterne, 69 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": "15 vergeben Sterne, 69 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",
+58 -9
View File
@@ -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",
+62 -7
View File
@@ -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",
+58 -9
View File
@@ -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",
+58 -9
View File
@@ -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",
+58 -9
View File
@@ -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",
+58 -9
View File
@@ -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 — 15 звёзды, 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": "Сортировка фото по умолчанию",
+58 -9
View File
@@ -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 15 zvezdice, 69 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": "15 določijo zvezdice, 69 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",
+20 -6
View File
@@ -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]);
+60 -2
View File
@@ -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;
}) {
+2
View File
@@ -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 {
+48
View File
@@ -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)
+6
View File
@@ -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();
});
});
+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;
}