diff --git a/src/components/panel/BottomBar.tsx b/src/components/panel/BottomBar.tsx
index 294d34b72d..3b56e1b173 100644
--- a/src/components/panel/BottomBar.tsx
+++ b/src/components/panel/BottomBar.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
-import { Star, Copy, ClipboardPaste, ChevronUp, ChevronDown, Check, FileInput, Settings } from 'lucide-react';
+import { Star, Copy, ClipboardPaste, ChevronUp, ChevronDown, Check, FileInput, Settings, Flag, FlagOff } from 'lucide-react';
import clsx from 'clsx';
import { motion, AnimatePresence } from 'framer-motion';
import { useShallow } from 'zustand/react/shallow';
@@ -9,6 +9,7 @@ import Filmstrip from './Filmstrip';
import { GLOBAL_KEYS, ImageFile, SelectedImage, ThumbnailAspectRatio } from '../ui/AppProperties';
import Text from '../ui/Text';
import { useEditorStore } from '../../store/useEditorStore';
+import { useLibraryActions } from '../../hooks/useLibraryActions';
interface BottomBarProps {
filmstripHeight?: number;
@@ -89,6 +90,57 @@ const StarRating = ({ rating, onRate, disabled }: StarRatingProps) => {
);
};
+interface FlagButtonsProps {
+ disabled: boolean;
+ flag: string | null;
+ onSetFlag(flag: 'picked' | 'rejected' | null): void;
+}
+
+const FlagButtons = ({ flag, onSetFlag, disabled }: FlagButtonsProps) => {
+ const { t } = useTranslation();
+
+ return (
+
;
rating: number;
rawStatus: RawStatus;
editedStatus?: EditedStatus;
+ flagStatus?: FlagStatus;
}
export interface Folder {
diff --git a/src/hooks/useAppContextMenus.ts b/src/hooks/useAppContextMenus.ts
index 77319b1708..35d717534e 100644
--- a/src/hooks/useAppContextMenus.ts
+++ b/src/hooks/useAppContextMenus.ts
@@ -41,6 +41,8 @@ import {
Briefcase,
User,
Album as AlbumIcon,
+ Flag,
+ FlagOff,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
@@ -75,7 +77,7 @@ export function useAppContextMenus(props: UseAppContextMenusProps) {
const { handleAutoAdjustments, handleResetAdjustments, handleCopyAdjustments, handlePasteAdjustments } =
useEditorActions();
- const { handleRate, handleSetColorLabel, handleTagsChanged } = useLibraryActions();
+ const { handleRate, handleSetColorLabel, handleSetFlag, handleTagsChanged } = useLibraryActions();
const albumIcons = useMemo(
() => [
@@ -265,6 +267,15 @@ export function useAppContextMenus(props: UseAppContextMenusProps) {
})),
],
},
+ {
+ label: t('contextMenus.editor.flag', 'Flag'),
+ icon: Flag,
+ submenu: [
+ { label: t('contextMenus.editor.flagPick', 'Pick'), icon: Flag, onClick: () => handleSetFlag('picked') },
+ { label: t('contextMenus.editor.flagReject', 'Reject'), icon: FlagOff, onClick: () => handleSetFlag('rejected') },
+ { label: t('contextMenus.editor.flagNone', 'Unflag'), icon: FlagOff, onClick: () => handleSetFlag(null) },
+ ],
+ },
{
label: t('contextMenus.editor.tagging'),
icon: Tag,
@@ -695,6 +706,15 @@ export function useAppContextMenus(props: UseAppContextMenusProps) {
})),
],
},
+ {
+ label: t('contextMenus.editor.flag', 'Flag'),
+ icon: Flag,
+ submenu: [
+ { label: t('contextMenus.editor.flagPick', 'Pick'), icon: Flag, onClick: () => handleSetFlag('picked', finalSelection) },
+ { label: t('contextMenus.editor.flagReject', 'Reject'), icon: FlagOff, onClick: () => handleSetFlag('rejected', finalSelection) },
+ { label: t('contextMenus.editor.flagNone', 'Unflag'), icon: FlagOff, onClick: () => handleSetFlag(null, finalSelection) },
+ ],
+ },
{
label: t('contextMenus.editor.tagging'),
icon: Tag,
diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts
index db61e463d0..73652e5c72 100644
--- a/src/hooks/useKeyboardShortcuts.ts
+++ b/src/hooks/useKeyboardShortcuts.ts
@@ -29,7 +29,7 @@ export const useKeyboardShortcuts = ({
handleZoomChange,
}: KeyboardShortcutsProps) => {
const { handleRotate, handleCopyAdjustments, handlePasteAdjustments } = useEditorActions();
- const { handleRate, handleSetColorLabel } = useLibraryActions();
+ const { handleRate, handleSetColorLabel, handleSetFlag } = useLibraryActions();
const sortedListRef = useRef(sortedImageList);
useEffect(() => {
@@ -429,6 +429,27 @@ export const useKeyboardShortcuts = ({
handleSetColorLabel('purple');
},
},
+ flag_picked: {
+ shouldFire: () => true,
+ execute: (e: any) => {
+ e.preventDefault();
+ handleSetFlag('picked');
+ },
+ },
+ flag_rejected: {
+ shouldFire: () => true,
+ execute: (e: any) => {
+ e.preventDefault();
+ handleSetFlag('rejected');
+ },
+ },
+ flag_none: {
+ shouldFire: () => true,
+ execute: (e: any) => {
+ e.preventDefault();
+ handleSetFlag(null);
+ },
+ },
brush_size_up: {
shouldFire: (s: any) =>
!!s.editor.selectedImage && !!s.editor.brushSettings && s.ui.activeRightPanel === Panel.Masks,
@@ -571,5 +592,6 @@ export const useKeyboardShortcuts = ({
handlePasteAdjustments,
handleRate,
handleSetColorLabel,
+ handleSetFlag,
]);
};
diff --git a/src/hooks/useLibraryActions.ts b/src/hooks/useLibraryActions.ts
index a54005a605..a716e55299 100644
--- a/src/hooks/useLibraryActions.ts
+++ b/src/hooks/useLibraryActions.ts
@@ -131,6 +131,45 @@ export function useLibraryActions(handleImageSelect?: (path: string) => void) {
}
}, []);
+ const handleSetFlag = useCallback(async (flag: 'picked' | 'rejected' | null, paths?: string[]) => {
+ const { multiSelectedPaths, libraryActivePath, imageList, setLibrary } = useLibraryStore.getState();
+ const { selectedImage } = useEditorStore.getState();
+
+ const pathsToUpdate =
+ paths || (multiSelectedPaths.length > 0 ? multiSelectedPaths : selectedImage ? [selectedImage.path] : []);
+ if (pathsToUpdate.length === 0) return;
+
+ const primaryPath = selectedImage?.path || libraryActivePath;
+ const primaryImage = imageList.find((img: ImageFile) => img.path === primaryPath);
+ let currentFlag: string | null = null;
+ if (primaryImage?.tags) {
+ const flagTag = primaryImage.tags.find((tag: string) => tag.startsWith('flag:'));
+ if (flagTag) currentFlag = flagTag.substring(5);
+ }
+ const finalFlag = flag !== null && flag === currentFlag ? null : flag;
+
+ // Optimistic update first
+ setLibrary((state) => ({
+ imageList: state.imageList.map((image: ImageFile) => {
+ if (pathsToUpdate.includes(image.path)) {
+ const otherTags = (image.tags || []).filter((tag: string) => !tag.startsWith('flag:'));
+ const newTags = finalFlag ? [...otherTags, `flag:${finalFlag}`] : otherTags;
+ return { ...image, tags: newTags };
+ }
+ return image;
+ }),
+ }));
+
+ for (const flagValue of ['picked', 'rejected']) {
+ await invoke(Invokes.RemoveTagForPaths, { paths: pathsToUpdate, tag: `flag:${flagValue}` }).catch(() => {});
+ }
+ if (finalFlag) {
+ await invoke(Invokes.AddTagForPaths, { paths: pathsToUpdate, tag: `flag:${finalFlag}` }).catch((err) => {
+ console.error('Failed to persist flag:', err);
+ });
+ }
+ }, []);
+
const handleClearSelection = useCallback(() => {
const { selectedImage } = useEditorStore.getState();
if (selectedImage) {
@@ -387,6 +426,7 @@ export function useLibraryActions(handleImageSelect?: (path: string) => void) {
return {
handleRate,
handleSetColorLabel,
+ handleSetFlag,
handleTagsChanged,
handleUpdateExif,
handleClearSelection,
diff --git a/src/hooks/useSortedLibrary.ts b/src/hooks/useSortedLibrary.ts
index 08bf9eb3e5..0d6b379052 100644
--- a/src/hooks/useSortedLibrary.ts
+++ b/src/hooks/useSortedLibrary.ts
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useLibraryStore } from '../store/useLibraryStore';
import { useSettingsStore } from '../store/useSettingsStore';
-import { RawStatus, EditedStatus, SortDirection, ImageFile } from '../components/ui/AppProperties';
+import { RawStatus, EditedStatus, FlagStatus, SortDirection, ImageFile } from '../components/ui/AppProperties';
export const ADVANCED_QUERY_REGEX =
/^(iso|aperture|f|shutter|s|focal|mm|rating|color|camera|make|model|lens)\s*(?::)?\s*(>=|<=|>|<|=)?\s*(.+)$/i;
@@ -124,6 +124,13 @@ export function computeSortedLibrary(libraryState: any, settingsState: any): Ima
if (!hasMatchingColor && !matchesNone) return false;
}
+ if (filterCriteria.flagStatus && filterCriteria.flagStatus !== FlagStatus.All) {
+ const flagTag = (image.tags || []).find((tag: string) => tag.startsWith('flag:'))?.substring(5);
+ if (filterCriteria.flagStatus === FlagStatus.FlaggedOnly && flagTag !== 'picked') return false;
+ if (filterCriteria.flagStatus === FlagStatus.RejectedOnly && flagTag !== 'rejected') return false;
+ if (filterCriteria.flagStatus === FlagStatus.UnflaggedOnly && flagTag) return false;
+ }
+
return true;
});
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index 7cb769a477..e1d50b6902 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -824,6 +824,12 @@
"editedOnly": "Nur bearbeitete",
"uneditedOnly": "Nur unbearbeitete"
},
+ "flag": {
+ "all": "Alle Bilder",
+ "flaggedOnly": "Nur markierte",
+ "rejectedOnly": "Nur abgelehnte",
+ "unflaggedOnly": "Nur unmarkierte"
+ },
"noMatch": "Keine Bilder gefunden, die Ihrem Filter entsprechen.",
"rating": {
"all": "Alle anzeigen",
@@ -895,6 +901,7 @@
"filterByColorLabel": "Nach Farbmarkierung filtern",
"filterByEdited": "Nach Bearbeitungsstatus filtern",
"filterByFileType": "Nach Dateityp filtern",
+ "filterByFlag": "Nach Markierung filtern",
"filterByRating": "Nach Bewertung filtern",
"metadataAlways": "Immer",
"metadataHover": "Beim Darüberfahren",
@@ -1421,6 +1428,9 @@
"color_label_blue": "Farbmarkierung: Blau",
"color_label_green": "Farbmarkierung: Grün",
"color_label_none": "Farbmarkierung: Keine",
+ "flag_none": "Markierung: Entfernen",
+ "flag_picked": "Markierung: Ausgewählt",
+ "flag_rejected": "Markierung: Abgelehnt",
"color_label_purple": "Farbmarkierung: Lila",
"color_label_red": "Farbmarkierung: Rot",
"color_label_yellow": "Farbmarkierung: Gelb",
@@ -1688,6 +1698,8 @@
"expandFilmstrip": "Filmstreifen erweitern",
"export": "Exportieren",
"pasteSettings": "Einstellungen einfügen",
+ "flagPick": "Auswählen (erneut klicken zum Entmarkieren)",
+ "flagReject": "Ablehnen (erneut klicken zum Entmarkieren)",
"rateStars_one": "Mit 1 Stern bewerten",
"rateStars_other": "Mit {{count}} Sternen bewerten",
"resetZoom": "Zoom auf Einpassen zurücksetzen",
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 638f74bd0a..0e2cba9ada 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -824,6 +824,12 @@
"editedOnly": "Edited Only",
"uneditedOnly": "Unedited Only"
},
+ "flag": {
+ "all": "All Images",
+ "flaggedOnly": "Flagged Only",
+ "rejectedOnly": "Rejected Only",
+ "unflaggedOnly": "Unflagged Only"
+ },
"noMatch": "No images found that match your filter.",
"rating": {
"all": "Show All",
@@ -895,6 +901,7 @@
"filterByColorLabel": "Filter by Color Label",
"filterByEdited": "Filter by Edit Status",
"filterByFileType": "Filter by File Type",
+ "filterByFlag": "Filter by Flag",
"filterByRating": "Filter by Rating",
"metadataAlways": "Always",
"metadataHover": "On Hover",
@@ -1421,6 +1428,9 @@
"color_label_blue": "Color label: Blue",
"color_label_green": "Color label: Green",
"color_label_none": "Color label: None",
+ "flag_none": "Flag: Unflag",
+ "flag_picked": "Flag: Pick",
+ "flag_rejected": "Flag: Reject",
"color_label_purple": "Color label: Purple",
"color_label_red": "Color label: Red",
"color_label_yellow": "Color label: Yellow",
@@ -1688,6 +1698,8 @@
"expandFilmstrip": "Expand Filmstrip",
"export": "Export",
"pasteSettings": "Paste Settings",
+ "flagPick": "Pick (click again to unflag)",
+ "flagReject": "Reject (click again to unflag)",
"rateStars_one": "Rate 1 star",
"rateStars_other": "Rate {{count}} stars",
"resetZoom": "Reset Zoom to Fit Window",
diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts
index 11e2a29515..1b300c759a 100644
--- a/src/utils/keyboardUtils.ts
+++ b/src/utils/keyboardUtils.ts
@@ -115,6 +115,24 @@ export const KEYBIND_DEFINITIONS: KeybindDefinition[] = [
defaultCombo: ['KeyB'],
section: 'view',
},
+ {
+ action: 'flag_picked',
+ description: 'settings.keybinds.actions.flag_picked',
+ defaultCombo: [],
+ section: 'rating',
+ },
+ {
+ action: 'flag_rejected',
+ description: 'settings.keybinds.actions.flag_rejected',
+ defaultCombo: ['KeyX'],
+ section: 'rating',
+ },
+ {
+ action: 'flag_none',
+ description: 'settings.keybinds.actions.flag_none',
+ defaultCombo: ['KeyU'],
+ section: 'rating',
+ },
{ action: 'rate_0', description: 'settings.keybinds.actions.rate_0', defaultCombo: ['Digit0'], section: 'rating' },
{ action: 'rate_1', description: 'settings.keybinds.actions.rate_1', defaultCombo: ['Digit1'], section: 'rating' },
{ action: 'rate_2', description: 'settings.keybinds.actions.rate_2', defaultCombo: ['Digit2'], section: 'rating' },