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 ( +
+ + +
+ ); +}; + export default function BottomBar({ filmstripHeight, imageList = [], @@ -125,6 +177,15 @@ export default function BottomBar({ totalImages, }: BottomBarProps) { const { t } = useTranslation(); + const { handleSetFlag } = useLibraryActions(); + + const currentFlag = + selectedImage + ? (imageList.find((img) => img.path === selectedImage.path)?.tags || []) + .find((tag) => tag.startsWith('flag:')) + ?.substring(5) ?? null + : null; + const { displaySize, originalSize } = useEditorStore( useShallow((state) => ({ displaySize: state.displaySize, @@ -279,6 +340,8 @@ export default function BottomBar({
+ +
+ +
+ + {t('library.header.viewOptions.filterByFlag', 'Filter by Flag')} + + {[ + { key: FlagStatus.All, label: t('library.filters.flag.all', 'All Images'), icon: null }, + { key: FlagStatus.FlaggedOnly, label: t('library.filters.flag.flaggedOnly', 'Flagged Only'), icon: }, + { key: FlagStatus.RejectedOnly, label: t('library.filters.flag.rejectedOnly', 'Rejected Only'), icon: }, + { key: FlagStatus.UnflaggedOnly, label: t('library.filters.flag.unflaggedOnly', 'Unflagged Only'), icon: null }, + ].map((option) => { + const isSelected = (filterCriteria.flagStatus || FlagStatus.All) === option.key; + return ( + + ); + })} +
diff --git a/src/components/panel/library/LibraryItems.tsx b/src/components/panel/library/LibraryItems.tsx index 1585203e43..bdd0758f9f 100644 --- a/src/components/panel/library/LibraryItems.tsx +++ b/src/components/panel/library/LibraryItems.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { Image as ImageIcon, Folder, FolderOpen, Star as StarIcon, SlidersHorizontal } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Image as ImageIcon, Folder, FolderOpen, Star as StarIcon, SlidersHorizontal, Flag, FlagOff } from 'lucide-react'; import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; import { COLOR_LABELS, Color } from '../../../utils/adjustments'; @@ -133,6 +134,7 @@ const ThumbnailComponent = ({ const colorTag = tags?.find((t: string) => t.startsWith('color:'))?.substring(6); const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag); + const flagTag = tags?.find((t: string) => t.startsWith('flag:'))?.substring(5) as 'picked' | 'rejected' | undefined; const isAlways = exifOverlay === ExifOverlay.Always; const isHover = exifOverlay === ExifOverlay.Hover; @@ -194,6 +196,26 @@ const ThumbnailComponent = ({ )} /> + + {flagTag && ( + + {flagTag === 'picked' ? ( + + ) : ( + + )} + + )} + +
; 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' },