Skip to content

Commit 9bd10bb

Browse files
marceldenzerclaude
andcommitted
feat: add photo flagging system (pick/reject)
Adds Lightroom-style photo flagging with keyboard shortcuts, filter support, and UI in the editor bottom bar and library thumbnails. - Flag/unflag photos as "picked" or "rejected" via keyboard shortcuts (X = reject, U = unflag, configurable pick) - Flag buttons (Flag + FlagOff icons) placed next to star rating in the editor bottom bar - Flag badge overlay (top-left) on library thumbnails to show flag state - Filter by flag status in the library view options dropdown - Context menu submenu for flagging in both editor and thumbnail contexts - Flags stored as tags (flag:picked / flag:rejected) using existing tag infrastructure - Optimistic UI updates for instant visual feedback - Full i18n support (en + de) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3a7899e commit 9bd10bb

11 files changed

Lines changed: 274 additions & 6 deletions

File tree

src/components/panel/BottomBar.tsx

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect, useRef } from 'react';
2-
import { Star, Copy, ClipboardPaste, ChevronUp, ChevronDown, Check, FileInput, Settings } from 'lucide-react';
2+
import { Star, Copy, ClipboardPaste, ChevronUp, ChevronDown, Check, FileInput, Settings, Flag, FlagOff } from 'lucide-react';
33
import clsx from 'clsx';
44
import { motion, AnimatePresence } from 'framer-motion';
55
import { useShallow } from 'zustand/react/shallow';
@@ -9,6 +9,7 @@ import Filmstrip from './Filmstrip';
99
import { GLOBAL_KEYS, ImageFile, SelectedImage, ThumbnailAspectRatio } from '../ui/AppProperties';
1010
import Text from '../ui/Text';
1111
import { useEditorStore } from '../../store/useEditorStore';
12+
import { useLibraryActions } from '../../hooks/useLibraryActions';
1213

1314
interface BottomBarProps {
1415
filmstripHeight?: number;
@@ -89,6 +90,57 @@ const StarRating = ({ rating, onRate, disabled }: StarRatingProps) => {
8990
);
9091
};
9192

93+
interface FlagButtonsProps {
94+
disabled: boolean;
95+
flag: string | null;
96+
onSetFlag(flag: 'picked' | 'rejected' | null): void;
97+
}
98+
99+
const FlagButtons = ({ flag, onSetFlag, disabled }: FlagButtonsProps) => {
100+
const { t } = useTranslation();
101+
102+
return (
103+
<div className={clsx('flex items-center gap-1', disabled && 'cursor-not-allowed')}>
104+
<button
105+
className="disabled:cursor-not-allowed"
106+
disabled={disabled}
107+
onClick={() => !disabled && onSetFlag('picked')}
108+
data-tooltip={disabled ? t('ui.bottomBar.tooltips.selectToRate') : t('ui.bottomBar.tooltips.flagPick')}
109+
>
110+
<Flag
111+
size={18}
112+
className={clsx(
113+
'transition-colors duration-150',
114+
disabled
115+
? 'text-text-secondary opacity-40'
116+
: flag === 'picked'
117+
? 'fill-green-500 text-green-500'
118+
: 'text-text-secondary hover:text-green-500',
119+
)}
120+
/>
121+
</button>
122+
<button
123+
className="disabled:cursor-not-allowed"
124+
disabled={disabled}
125+
onClick={() => !disabled && onSetFlag('rejected')}
126+
data-tooltip={disabled ? t('ui.bottomBar.tooltips.selectToRate') : t('ui.bottomBar.tooltips.flagReject')}
127+
>
128+
<FlagOff
129+
size={18}
130+
className={clsx(
131+
'transition-colors duration-150',
132+
disabled
133+
? 'text-text-secondary opacity-40'
134+
: flag === 'rejected'
135+
? 'fill-red-500 text-red-500'
136+
: 'text-text-secondary hover:text-red-500',
137+
)}
138+
/>
139+
</button>
140+
</div>
141+
);
142+
};
143+
92144
export default function BottomBar({
93145
filmstripHeight,
94146
imageList = [],
@@ -125,6 +177,15 @@ export default function BottomBar({
125177
totalImages,
126178
}: BottomBarProps) {
127179
const { t } = useTranslation();
180+
const { handleSetFlag } = useLibraryActions();
181+
182+
const currentFlag =
183+
selectedImage
184+
? (imageList.find((img) => img.path === selectedImage.path)?.tags || [])
185+
.find((tag) => tag.startsWith('flag:'))
186+
?.substring(5) ?? null
187+
: null;
188+
128189
const { displaySize, originalSize } = useEditorStore(
129190
useShallow((state) => ({
130191
displaySize: state.displaySize,
@@ -279,6 +340,8 @@ export default function BottomBar({
279340
<div className="flex items-center gap-4">
280341
<StarRating rating={rating} onRate={onRate} disabled={isRatingDisabled} />
281342
<div className="h-5 w-px bg-surface"></div>
343+
<FlagButtons flag={currentFlag} onSetFlag={handleSetFlag} disabled={isRatingDisabled} />
344+
<div className="h-5 w-px bg-surface"></div>
282345
<div className="flex items-center gap-2">
283346
<button
284347
className="relative w-8 h-8 flex items-center justify-center rounded-md text-text-secondary hover:bg-surface hover:text-text-primary transition-colors disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed"

src/components/panel/library/LibraryHeader.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
ChevronUp,
1111
ChevronDown,
1212
HelpCircle,
13+
Flag,
14+
FlagOff,
1315
} from 'lucide-react';
1416
import { useTranslation } from 'react-i18next';
1517
import { useShallow } from 'zustand/react/shallow';
@@ -18,6 +20,7 @@ import {
1820
FilterCriteria,
1921
RawStatus,
2022
EditedStatus,
23+
FlagStatus,
2124
LibraryViewMode,
2225
SortCriteria,
2326
SortDirection,
@@ -332,7 +335,8 @@ export function ViewOptionsDropdown({
332335
const isFilterActive =
333336
filterCriteria.rating !== 0 ||
334337
(filterCriteria.rawStatus && filterCriteria.rawStatus !== RawStatus.All) ||
335-
(filterCriteria.colors && filterCriteria.colors.length > 0);
338+
(filterCriteria.colors && filterCriteria.colors.length > 0) ||
339+
(filterCriteria.flagStatus && filterCriteria.flagStatus !== FlagStatus.All);
336340

337341
const [lastClickedColor, setLastClickedColor] = useState<string | null>(null);
338342
const allColors = useMemo(() => [...COLOR_LABELS, { name: 'none', color: '#9ca3af' }], []);
@@ -655,6 +659,44 @@ export function ViewOptionsDropdown({
655659
);
656660
})}
657661
</div>
662+
663+
<div>
664+
<Text as="div" variant={TextVariants.small} weight={TextWeights.semibold} className="px-3 py-2 uppercase">
665+
{t('library.header.viewOptions.filterByFlag', 'Filter by Flag')}
666+
</Text>
667+
{[
668+
{ key: FlagStatus.All, label: t('library.filters.flag.all', 'All Images'), icon: null },
669+
{ key: FlagStatus.FlaggedOnly, label: t('library.filters.flag.flaggedOnly', 'Flagged Only'), icon: <Flag size={14} className="text-green-400" /> },
670+
{ key: FlagStatus.RejectedOnly, label: t('library.filters.flag.rejectedOnly', 'Rejected Only'), icon: <FlagOff size={14} className="text-red-400" /> },
671+
{ key: FlagStatus.UnflaggedOnly, label: t('library.filters.flag.unflaggedOnly', 'Unflagged Only'), icon: null },
672+
].map((option) => {
673+
const isSelected = (filterCriteria.flagStatus || FlagStatus.All) === option.key;
674+
return (
675+
<button
676+
className={`w-full text-left px-3 py-2 rounded-md flex items-center justify-between transition-colors duration-150 ${
677+
isSelected ? 'bg-card-active' : 'hover:bg-bg-primary'
678+
}`}
679+
key={option.key}
680+
onClick={() =>
681+
setFilterCriteria({ flagStatus: option.key as FlagStatus })
682+
}
683+
role="menuitem"
684+
>
685+
<span className="flex items-center gap-2">
686+
{option.icon}
687+
<Text
688+
variant={TextVariants.label}
689+
color={TextColors.primary}
690+
weight={isSelected ? TextWeights.semibold : TextWeights.normal}
691+
>
692+
{option.label}
693+
</Text>
694+
</span>
695+
{isSelected && <Check size={16} className={TEXT_COLOR_KEYS[TextColors.primary]} />}
696+
</button>
697+
);
698+
})}
699+
</div>
658700
</div>
659701

660702
<div className="py-2"></div>

src/components/panel/library/LibraryItems.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
2-
import { Image as ImageIcon, Folder, FolderOpen, Star as StarIcon, SlidersHorizontal } from 'lucide-react';
2+
import { motion, AnimatePresence } from 'framer-motion';
3+
import { Image as ImageIcon, Folder, FolderOpen, Star as StarIcon, SlidersHorizontal, Flag, FlagOff } from 'lucide-react';
34
import clsx from 'clsx';
45
import { useTranslation } from 'react-i18next';
56
import { COLOR_LABELS, Color } from '../../../utils/adjustments';
@@ -133,6 +134,7 @@ const ThumbnailComponent = ({
133134

134135
const colorTag = tags?.find((t: string) => t.startsWith('color:'))?.substring(6);
135136
const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag);
137+
const flagTag = tags?.find((t: string) => t.startsWith('flag:'))?.substring(5) as 'picked' | 'rejected' | undefined;
136138

137139
const isAlways = exifOverlay === ExifOverlay.Always;
138140
const isHover = exifOverlay === ExifOverlay.Hover;
@@ -194,6 +196,26 @@ const ThumbnailComponent = ({
194196
)}
195197
/>
196198

199+
<AnimatePresence initial={false}>
200+
{flagTag && (
201+
<motion.div
202+
key="flag-badge"
203+
initial={{ opacity: 0, scale: 0.8, y: -5 }}
204+
animate={{ opacity: 1, scale: 1, y: 0 }}
205+
exit={{ opacity: 0, scale: 0.8, y: -5 }}
206+
transition={{ duration: 0.25, type: 'spring', bounce: 0.3 }}
207+
className="absolute top-2 left-2 z-10 pointer-events-none rounded-full p-1 backdrop-blur-md shadow-md"
208+
style={{ backgroundColor: flagTag === 'picked' ? 'rgba(34,197,94,0.25)' : 'rgba(239,68,68,0.25)' }}
209+
>
210+
{flagTag === 'picked' ? (
211+
<Flag size={11} className="text-green-400 fill-green-400" />
212+
) : (
213+
<FlagOff size={11} className="text-red-400" />
214+
)}
215+
</motion.div>
216+
)}
217+
</AnimatePresence>
218+
197219
<div className="absolute top-1.5 right-1.5 flex items-center justify-end z-10 pointer-events-none">
198220
<div
199221
className={clsx(

src/components/ui/AppProperties.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export const GLOBAL_KEYS = [
1919
'p',
2020
'i',
2121
'e',
22+
'x',
23+
'u',
2224
'0',
2325
'1',
2426
'2',
@@ -223,11 +225,19 @@ export const EditedStatus = {
223225

224226
export type EditedStatus = (typeof EditedStatus)[keyof typeof EditedStatus];
225227

228+
export enum FlagStatus {
229+
All = 'all',
230+
FlaggedOnly = 'flaggedOnly',
231+
RejectedOnly = 'rejectedOnly',
232+
UnflaggedOnly = 'unflaggedOnly',
233+
}
234+
226235
export interface FilterCriteria {
227236
colors: Array<string>;
228237
rating: number;
229238
rawStatus: RawStatus;
230239
editedStatus?: EditedStatus;
240+
flagStatus?: FlagStatus;
231241
}
232242

233243
export interface Folder {

src/hooks/useAppContextMenus.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import {
4141
Briefcase,
4242
User,
4343
Album as AlbumIcon,
44+
Flag,
45+
FlagOff,
4446
} from 'lucide-react';
4547
import { toast } from 'react-toastify';
4648
import { useTranslation } from 'react-i18next';
@@ -75,7 +77,7 @@ export function useAppContextMenus(props: UseAppContextMenusProps) {
7577

7678
const { handleAutoAdjustments, handleResetAdjustments, handleCopyAdjustments, handlePasteAdjustments } =
7779
useEditorActions();
78-
const { handleRate, handleSetColorLabel, handleTagsChanged } = useLibraryActions();
80+
const { handleRate, handleSetColorLabel, handleSetFlag, handleTagsChanged } = useLibraryActions();
7981

8082
const albumIcons = useMemo(
8183
() => [
@@ -265,6 +267,15 @@ export function useAppContextMenus(props: UseAppContextMenusProps) {
265267
})),
266268
],
267269
},
270+
{
271+
label: t('contextMenus.editor.flag', 'Flag'),
272+
icon: Flag,
273+
submenu: [
274+
{ label: t('contextMenus.editor.flagPick', 'Pick'), icon: Flag, onClick: () => handleSetFlag('picked') },
275+
{ label: t('contextMenus.editor.flagReject', 'Reject'), icon: FlagOff, onClick: () => handleSetFlag('rejected') },
276+
{ label: t('contextMenus.editor.flagNone', 'Unflag'), icon: FlagOff, onClick: () => handleSetFlag(null) },
277+
],
278+
},
268279
{
269280
label: t('contextMenus.editor.tagging'),
270281
icon: Tag,
@@ -695,6 +706,15 @@ export function useAppContextMenus(props: UseAppContextMenusProps) {
695706
})),
696707
],
697708
},
709+
{
710+
label: t('contextMenus.editor.flag', 'Flag'),
711+
icon: Flag,
712+
submenu: [
713+
{ label: t('contextMenus.editor.flagPick', 'Pick'), icon: Flag, onClick: () => handleSetFlag('picked', finalSelection) },
714+
{ label: t('contextMenus.editor.flagReject', 'Reject'), icon: FlagOff, onClick: () => handleSetFlag('rejected', finalSelection) },
715+
{ label: t('contextMenus.editor.flagNone', 'Unflag'), icon: FlagOff, onClick: () => handleSetFlag(null, finalSelection) },
716+
],
717+
},
698718
{
699719
label: t('contextMenus.editor.tagging'),
700720
icon: Tag,

src/hooks/useKeyboardShortcuts.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const useKeyboardShortcuts = ({
2929
handleZoomChange,
3030
}: KeyboardShortcutsProps) => {
3131
const { handleRotate, handleCopyAdjustments, handlePasteAdjustments } = useEditorActions();
32-
const { handleRate, handleSetColorLabel } = useLibraryActions();
32+
const { handleRate, handleSetColorLabel, handleSetFlag } = useLibraryActions();
3333

3434
const sortedListRef = useRef(sortedImageList);
3535
useEffect(() => {
@@ -429,6 +429,27 @@ export const useKeyboardShortcuts = ({
429429
handleSetColorLabel('purple');
430430
},
431431
},
432+
flag_picked: {
433+
shouldFire: () => true,
434+
execute: (e: any) => {
435+
e.preventDefault();
436+
handleSetFlag('picked');
437+
},
438+
},
439+
flag_rejected: {
440+
shouldFire: () => true,
441+
execute: (e: any) => {
442+
e.preventDefault();
443+
handleSetFlag('rejected');
444+
},
445+
},
446+
flag_none: {
447+
shouldFire: () => true,
448+
execute: (e: any) => {
449+
e.preventDefault();
450+
handleSetFlag(null);
451+
},
452+
},
432453
brush_size_up: {
433454
shouldFire: (s: any) =>
434455
!!s.editor.selectedImage && !!s.editor.brushSettings && s.ui.activeRightPanel === Panel.Masks,
@@ -571,5 +592,6 @@ export const useKeyboardShortcuts = ({
571592
handlePasteAdjustments,
572593
handleRate,
573594
handleSetColorLabel,
595+
handleSetFlag,
574596
]);
575597
};

src/hooks/useLibraryActions.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,45 @@ export function useLibraryActions(handleImageSelect?: (path: string) => void) {
131131
}
132132
}, []);
133133

134+
const handleSetFlag = useCallback(async (flag: 'picked' | 'rejected' | null, paths?: string[]) => {
135+
const { multiSelectedPaths, libraryActivePath, imageList, setLibrary } = useLibraryStore.getState();
136+
const { selectedImage } = useEditorStore.getState();
137+
138+
const pathsToUpdate =
139+
paths || (multiSelectedPaths.length > 0 ? multiSelectedPaths : selectedImage ? [selectedImage.path] : []);
140+
if (pathsToUpdate.length === 0) return;
141+
142+
const primaryPath = selectedImage?.path || libraryActivePath;
143+
const primaryImage = imageList.find((img: ImageFile) => img.path === primaryPath);
144+
let currentFlag: string | null = null;
145+
if (primaryImage?.tags) {
146+
const flagTag = primaryImage.tags.find((tag: string) => tag.startsWith('flag:'));
147+
if (flagTag) currentFlag = flagTag.substring(5);
148+
}
149+
const finalFlag = flag !== null && flag === currentFlag ? null : flag;
150+
151+
// Optimistic update first
152+
setLibrary((state) => ({
153+
imageList: state.imageList.map((image: ImageFile) => {
154+
if (pathsToUpdate.includes(image.path)) {
155+
const otherTags = (image.tags || []).filter((tag: string) => !tag.startsWith('flag:'));
156+
const newTags = finalFlag ? [...otherTags, `flag:${finalFlag}`] : otherTags;
157+
return { ...image, tags: newTags };
158+
}
159+
return image;
160+
}),
161+
}));
162+
163+
for (const flagValue of ['picked', 'rejected']) {
164+
await invoke(Invokes.RemoveTagForPaths, { paths: pathsToUpdate, tag: `flag:${flagValue}` }).catch(() => {});
165+
}
166+
if (finalFlag) {
167+
await invoke(Invokes.AddTagForPaths, { paths: pathsToUpdate, tag: `flag:${finalFlag}` }).catch((err) => {
168+
console.error('Failed to persist flag:', err);
169+
});
170+
}
171+
}, []);
172+
134173
const handleClearSelection = useCallback(() => {
135174
const { selectedImage } = useEditorStore.getState();
136175
if (selectedImage) {
@@ -387,6 +426,7 @@ export function useLibraryActions(handleImageSelect?: (path: string) => void) {
387426
return {
388427
handleRate,
389428
handleSetColorLabel,
429+
handleSetFlag,
390430
handleTagsChanged,
391431
handleUpdateExif,
392432
handleClearSelection,

0 commit comments

Comments
 (0)