From ad1d18f1452b9e3a51a58210a4af0723313430e9 Mon Sep 17 00:00:00 2001 From: Floh <48927090+Flohhhhh@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:45:37 -0400 Subject: [PATCH 1/7] Add rejected ratings support --- src-tauri/src/file_management.rs | 17 +- src-tauri/src/image_processing.rs | 2 +- src/App.tsx | 91 +++++++-- src/components/modals/CullingModal.tsx | 2 +- src/components/panel/Filmstrip.tsx | 87 ++++---- src/components/panel/MainLibrary.tsx | 202 +++++++++++++------ src/components/panel/SettingsPanel.tsx | 1 + src/components/panel/right/MetadataPanel.tsx | 13 +- src/components/ui/AppProperties.tsx | 10 + src/hooks/useKeyboardShortcuts.tsx | 9 + 10 files changed, 310 insertions(+), 124 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index d2b308b42..3812777d9 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -118,16 +118,23 @@ pub struct SortCriteria { #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct FilterCriteria { - pub rating: u8, + pub rating: i8, + #[serde(default = "default_rejected_status")] + pub rejected_status: String, pub raw_status: String, #[serde(default)] pub colors: Vec, } +fn default_rejected_status() -> String { + "all".to_string() +} + impl Default for FilterCriteria { fn default() -> Self { Self { rating: 0, + rejected_status: default_rejected_status(), raw_status: "all".to_string(), colors: Vec::new(), } @@ -534,7 +541,7 @@ pub struct ImageFile { path: String, modified: u64, is_edited: bool, - rating: u8, + rating: i8, tags: Option>, exif: Option>, is_virtual_copy: bool, @@ -1656,7 +1663,7 @@ fn generate_single_thumbnail_and_cache( preloaded_image: Option<&DynamicImage>, force_regenerate: bool, app_handle: &AppHandle, -) -> Option<(String, u8)> { +) -> Option<(String, i8)> { let (source_path, sidecar_path) = parse_virtual_path(path_str); let img_mod_time = fs::metadata(source_path) @@ -2599,7 +2606,7 @@ pub fn set_color_label_for_paths( #[tauri::command] pub fn set_rating_for_paths( paths: Vec, - rating: u8, + rating: i8, app_handle: AppHandle, ) -> Result<(), String> { let settings = load_settings(app_handle.clone()).unwrap_or_default(); @@ -3831,7 +3838,7 @@ pub fn create_virtual_copy(source_virtual_path: String) -> Result Option { +pub fn extract_xmp_rating(content: &str) -> Option { if let Some(idx) = content.find("xmp:Rating=\"") { let start = idx + 12; let end = content[start..].find('"').map(|i| start + i)?; diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index d425bf7d1..7de23b5a1 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -51,7 +51,7 @@ impl<'a> IntoCowImage<'a> for &'a std::sync::Arc { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ImageMetadata { pub version: u32, - pub rating: u8, + pub rating: i8, pub adjustments: Value, #[serde(default)] pub tags: Option>, diff --git a/src/App.tsx b/src/App.tsx index 14170451f..40c54adce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -117,6 +117,8 @@ import { ThumbnailSize, ThumbnailAspectRatio, CullingSuggestions, + RejectedFilterStatus, + REJECTED_RATING, } from './components/ui/AppProperties'; import { ChannelConfig } from './components/adjustments/Curves'; import HdrModal from './components/modals/HdrModal'; @@ -288,6 +290,7 @@ function App() { const [filterCriteria, setFilterCriteria] = useState({ colors: [], rating: 0, + rejectedStatus: RejectedFilterStatus.All, rawStatus: RawStatus.All, }); const [supportedTypes, setSupportedTypes] = useState(null); @@ -1191,8 +1194,9 @@ function App() { } const filteredList = processedList.filter((image) => { + const rating = imageRatings[image.path] ?? 0; + if (filterCriteria.rating > 0) { - const rating = imageRatings[image.path] || 0; if (filterCriteria.rating === 5) { if (rating !== 5) return false; } else { @@ -1200,6 +1204,14 @@ function App() { } } + if (filterCriteria.rejectedStatus === RejectedFilterStatus.RejectedOnly && rating !== REJECTED_RATING) { + return false; + } + + if (filterCriteria.rejectedStatus === RejectedFilterStatus.UnrejectedOnly && rating === REJECTED_RATING) { + return false; + } + if ( filterCriteria.rawStatus && filterCriteria.rawStatus !== RawStatus.All && @@ -1819,6 +1831,7 @@ function App() { setFilterCriteria((prev: FilterCriteria) => ({ ...prev, ...settings.filterCriteria, + rejectedStatus: settings.filterCriteria.rejectedStatus || RejectedFilterStatus.All, rawStatus: settings.filterCriteria.rawStatus || RawStatus.All, colors: settings.filterCriteria.colors || [], })); @@ -2742,10 +2755,41 @@ function App() { } }; + const getRatingTargetPaths = useCallback( + (paths?: Array) => + paths || + (multiSelectedPaths.length > 0 + ? multiSelectedPaths + : selectedImage + ? [selectedImage.path] + : libraryActivePath + ? [libraryActivePath] + : []), + [libraryActivePath, multiSelectedPaths, selectedImage], + ); + + const applyRatingToPaths = useCallback((pathsToRate: string[], nextRating: number) => { + if (pathsToRate.length === 0) { + return; + } + + setImageRatings((prev: Record) => { + const newRatings = { ...prev }; + pathsToRate.forEach((path: string) => { + newRatings[path] = nextRating; + }); + return newRatings; + }); + + invoke(Invokes.SetRatingForPaths, { paths: pathsToRate, rating: nextRating }).catch((err) => { + console.error('Failed to apply rating to paths:', err); + setError(`Failed to apply rating: ${err}`); + }); + }, []); + const handleRate = useCallback( (newRating: number, paths?: Array) => { - const pathsToRate = - paths || (multiSelectedPaths.length > 0 ? multiSelectedPaths : selectedImage ? [selectedImage.path] : []); + const pathsToRate = getRatingTargetPaths(paths); if (pathsToRate.length === 0) { return; } @@ -2753,20 +2797,24 @@ function App() { const currentRating = imageRatings[pathsToRate[0]] || 0; const finalRating = newRating === currentRating ? 0 : newRating; - setImageRatings((prev: Record) => { - const newRatings = { ...prev }; - pathsToRate.forEach((path: string) => { - newRatings[path] = finalRating; - }); - return newRatings; - }); + applyRatingToPaths(pathsToRate, finalRating); + }, + [applyRatingToPaths, getRatingTargetPaths, imageRatings], + ); - invoke(Invokes.SetRatingForPaths, { paths: pathsToRate, rating: finalRating }).catch((err) => { - console.error('Failed to apply rating to paths:', err); - setError(`Failed to apply rating: ${err}`); - }); + const handleToggleRejected = useCallback( + (paths?: Array) => { + const pathsToRate = getRatingTargetPaths(paths); + if (pathsToRate.length === 0) { + return; + } + + const currentRating = imageRatings[pathsToRate[0]] || 0; + const finalRating = currentRating === REJECTED_RATING ? 0 : REJECTED_RATING; + + applyRatingToPaths(pathsToRate, finalRating); }, - [multiSelectedPaths, selectedImage, imageRatings], + [applyRatingToPaths, getRatingTargetPaths, imageRatings], ); const handleSetColorLabel = useCallback( @@ -3186,6 +3234,7 @@ function App() { handlePasteAdjustments, handlePasteFiles, handleRate, + handleToggleRejected, handleRightPanelSelect, handleRotate, handleSetColorLabel, @@ -4317,6 +4366,11 @@ function App() { onClick: () => handleRate(rating), })), }, + { + label: 'Rejected', + icon: X, + onClick: () => handleToggleRejected(), + }, { label: 'Color Label', icon: Palette, @@ -4714,6 +4768,11 @@ function App() { onClick: () => handleRate(rating, finalSelection), })), }, + { + label: 'Rejected', + icon: X, + onClick: () => handleToggleRejected(finalSelection), + }, { label: 'Color Label', icon: Palette, @@ -5637,7 +5696,7 @@ function App() { thumbnails={thumbnails} onApply={(action, paths) => { if (action === 'reject') { - handleSetColorLabel('red', paths); + applyRatingToPaths(paths, REJECTED_RATING); } else if (action === 'rate_zero') { handleRate(1, paths); } else if (action === 'delete') { diff --git a/src/components/modals/CullingModal.tsx b/src/components/modals/CullingModal.tsx index a68ade58f..c57d19a2e 100644 --- a/src/components/modals/CullingModal.tsx +++ b/src/components/modals/CullingModal.tsx @@ -29,7 +29,7 @@ const CULL_ACTIONS: { label: string; icon: React.ReactNode; }[] = [ - { value: 'reject', label: 'Mark as Rejected (Red Label)', icon: }, + { value: 'reject', label: 'Mark as Rejected', icon: }, { value: 'rate_zero', label: 'Set Rating to 1 Stars', icon: }, { value: 'delete', label: 'Move to Trash', icon: }, ]; diff --git a/src/components/panel/Filmstrip.tsx b/src/components/panel/Filmstrip.tsx index 20d3b32ce..e1459870b 100644 --- a/src/components/panel/Filmstrip.tsx +++ b/src/components/panel/Filmstrip.tsx @@ -1,9 +1,9 @@ import React, { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react'; -import { Image as ImageIcon, Star } from 'lucide-react'; +import { Image as ImageIcon, Star, X } from 'lucide-react'; import { motion } from 'framer-motion'; import clsx from 'clsx'; import { Grid, useGridCallbackRef } from 'react-window'; -import { ImageFile, SelectedImage, ThumbnailAspectRatio } from '../ui/AppProperties'; +import { ImageFile, SelectedImage, ThumbnailAspectRatio, REJECTED_RATING } from '../ui/AppProperties'; import { Color, COLOR_LABELS } from '../../utils/adjustments'; import Text from '../ui/Text'; import { TextColors, TextVariants, TextWeights } from '../../types/typography'; @@ -11,6 +11,7 @@ import { TextColors, TextVariants, TextWeights } from '../../types/typography'; const VERTICAL_PADDING = 24; const HORIZONTAL_PADDING = 4; const ITEM_GAP = 8; +const isRejectedRating = (rating: number) => rating === REJECTED_RATING; interface ImageLayer { id: string; @@ -71,6 +72,8 @@ const FilmstripThumbnail = memo( const rating = imageRatings?.[path] || 0; const colorTag = tags?.find((t: string) => t.startsWith('color:'))?.substring(6); const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag); + const isRejected = isRejectedRating(rating); + const contentOpacityClass = isRejected ? 'opacity-60' : ''; const isVirtualCopy = path.includes('?vc='); const cleanPath = path.split('?')[0]; @@ -173,49 +176,57 @@ const FilmstripThumbnail = memo( }} data-tooltip={truncatedTitle} > - {layers.length > 0 ? ( -
- {layers.map((layer) => ( -
handleTransitionEnd(layer.id)} - > - {thumbnailAspectRatio === ThumbnailAspectRatio.Contain && ( +
+ {layers.length > 0 ? ( +
+ {layers.map((layer) => ( +
handleTransitionEnd(layer.id)} + > + {thumbnailAspectRatio === ThumbnailAspectRatio.Contain && ( + + )} - )} - {truncatedTitle} -
- ))} -
- ) : ( -
- -
- )} +
+ ))} +
+ ) : ( +
+ +
+ )} +
- {(colorLabel || rating > 0) && ( + {(colorLabel || rating > 0 || isRejected) && ( <>
+ {isRejected && ( + <> + + Rejected + + )} {colorLabel && (
)} - {rating > 0 && ( + {!isRejected && rating > 0 && ( <> {rating} diff --git a/src/components/panel/MainLibrary.tsx b/src/components/panel/MainLibrary.tsx index b7883a52e..7f7d17b2b 100644 --- a/src/components/panel/MainLibrary.tsx +++ b/src/components/panel/MainLibrary.tsx @@ -34,6 +34,8 @@ import { LibraryViewMode, Progress, RawStatus, + RejectedFilterStatus, + REJECTED_RATING, SortCriteria, SortDirection, SupportedTypes, @@ -214,6 +216,14 @@ const rawStatusOptions: Array = [ { key: RawStatus.RawOverNonRaw, label: 'Prefer RAW' }, ]; +const rejectedStatusOptions: Array = [ + { key: RejectedFilterStatus.All, label: 'All Images' }, + { key: RejectedFilterStatus.UnrejectedOnly, label: 'Unrejected Only' }, + { key: RejectedFilterStatus.RejectedOnly, label: 'Rejected Only' }, +]; + +const isRejectedRating = (rating: number) => rating === REJECTED_RATING; + const thumbnailSizeOptions: Array = [ { id: ThumbnailSize.Small, label: 'Small', size: 160 }, { id: ThumbnailSize.Medium, label: 'Medium', size: 240 }, @@ -763,6 +773,13 @@ function FilterOptions({ filterCriteria, setFilterCriteria }: FilterOptionProps) setFilterCriteria((prev: Partial) => ({ ...prev, rating })); }; + const handleRejectedStatusChange = (rejectedStatus: RejectedFilterStatus | undefined) => { + setFilterCriteria((prev: Partial) => ({ + ...prev, + rejectedStatus: rejectedStatus || RejectedFilterStatus.All, + })); + }; + const handleRawStatusChange = (rawStatus: RawStatus | undefined) => { setFilterCriteria((prev: Partial) => ({ ...prev, rawStatus })); }; @@ -801,6 +818,34 @@ function FilterOptions({ filterCriteria, setFilterCriteria }: FilterOptionProps) })}
+
+ + Filter by Status + + {rejectedStatusOptions.map((option: KeyValueLabel) => { + const isSelected = (filterCriteria.rejectedStatus || RejectedFilterStatus.All) === option.key; + return ( + + ); + })} +
+
Filter by File Type @@ -946,6 +991,7 @@ function ViewOptionsDropdown({ }: ViewOptionsProps) { const isFilterActive = filterCriteria.rating > 0 || + (filterCriteria.rejectedStatus && filterCriteria.rejectedStatus !== RejectedFilterStatus.All) || (filterCriteria.rawStatus && filterCriteria.rawStatus !== RawStatus.All) || (filterCriteria.colors && filterCriteria.colors.length > 0); @@ -1060,6 +1106,8 @@ function ListItem({ const colorTag = tags?.find((t: string) => t.startsWith('color:'))?.substring(6); const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag); + const isRejected = isRejectedRating(rating); + const contentOpacityClass = isRejected ? 'opacity-60' : ''; const dateObj = new Date(modified > 1e11 ? modified : modified * 1000); const dateStr = @@ -1085,7 +1133,7 @@ function ListItem({ >
{layers.length > 0 && ( @@ -1128,7 +1176,10 @@ function ListItem({
{/* Name */} -
+
{baseName} @@ -1146,14 +1197,25 @@ function ListItem({ )}
-
+
{dateStr}
- {rating > 0 && ( + {isRejected && ( +
+ + + Rejected + +
+ )} + {!isRejected && rating > 0 && (
@@ -1163,7 +1225,10 @@ function ListItem({ )}
-
+
{colorLabel && (
t.startsWith('color:'))?.substring(6); const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag); + const isRejected = isRejectedRating(rating); + const contentOpacityClass = isRejected ? 'opacity-60' : ''; return (
onImageDoubleClick(path)} > - {layers.length > 0 && ( -
- {layers.map((layer) => ( -
handleTransitionEnd(layer.id)} +
+ {layers.length > 0 && ( +
+ {layers.map((layer) => ( +
handleTransitionEnd(layer.id)} + > + {path.split(/[\\/]/).pop()} +
+ ))} +
+ )} + + + {layers.length === 0 && showPlaceholder && ( + - {path.split(/[\\/]/).pop()} -
- ))} -
- )} + + + )} + - - {layers.length === 0 && showPlaceholder && ( - - - - )} - +
+ + {baseName} + + {isVirtualCopy && ( + + VC + + )} +
+
- {(colorLabel || rating > 0) && ( + {(colorLabel || rating > 0 || isRejected) && ( <>
-
+
+ {isRejected && ( + <> + + + Rejected + + + )} {colorLabel && (
)} - {rating > 0 && ( + {!isRejected && rating > 0 && ( <> {rating} @@ -1338,23 +1433,6 @@ function Thumbnail({
)} -
- - {baseName} - - {isVirtualCopy && ( - - VC - - )} -
); } diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index 9a3d64831..a91d64c75 100644 --- a/src/components/panel/SettingsPanel.tsx +++ b/src/components/panel/SettingsPanel.tsx @@ -1789,6 +1789,7 @@ export default function SettingsPanel({ keys={osPlatform === 'macos' ? ['Cmd', '+', 'Delete'] : ['Delete']} description="Delete selected file(s)" /> + diff --git a/src/components/panel/right/MetadataPanel.tsx b/src/components/panel/right/MetadataPanel.tsx index dd4d6e17d..27ad26e28 100644 --- a/src/components/panel/right/MetadataPanel.tsx +++ b/src/components/panel/right/MetadataPanel.tsx @@ -3,7 +3,7 @@ import { invoke } from '@tauri-apps/api/core'; import { Check, ChevronDown, ChevronRight, Plus, Star, Tag, X } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import clsx from 'clsx'; -import { SelectedImage, AppSettings, Invokes } from '../../ui/AppProperties'; +import { SelectedImage, AppSettings, Invokes, REJECTED_RATING } from '../../ui/AppProperties'; import { COLOR_LABELS, Color } from '../../../utils/adjustments'; import Text from '../../ui/Text'; import { TextColors, TextVariants, TextWeights } from '../../../types/typography'; @@ -289,6 +289,17 @@ export default function MetadataPanel({ /> ))} +
diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index cd464b76f..068786d3b 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -19,6 +19,7 @@ export const GLOBAL_KEYS = [ 'p', 'i', 'e', + 'x', '0', '1', '2', @@ -124,6 +125,14 @@ export enum RawStatus { RawOverNonRaw = 'rawOverNonRaw', } +export enum RejectedFilterStatus { + All = 'all', + RejectedOnly = 'rejectedOnly', + UnrejectedOnly = 'unrejectedOnly', +} + +export const REJECTED_RATING = -1; + export enum SortDirection { Ascending = 'asc', Descening = 'desc', @@ -197,6 +206,7 @@ export enum LibraryViewMode { export interface FilterCriteria { colors: Array; rating: number; + rejectedStatus: RejectedFilterStatus; rawStatus: RawStatus; } diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index 606c58204..0b06e81db 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -25,6 +25,7 @@ interface KeyboardShortcutsProps { handleRightPanelSelect(panel: Panel): void; handleRotate(degrees: number): void; handleSetColorLabel(label: string | null): void; + handleToggleRejected(): void; handleToggleFullScreen(): void; handleZoomChange(zoomValue: number, fitToWindow?: boolean): void; isFullScreen: boolean; @@ -78,6 +79,7 @@ export const useKeyboardShortcuts = ({ handleRightPanelSelect, handleRotate, handleSetColorLabel, + handleToggleRejected, handleToggleFullScreen, handleZoomChange, isFullScreen, @@ -251,6 +253,12 @@ export const useKeyboardShortcuts = ({ } } + if (key === 'x' && !isCtrl) { + event.preventDefault(); + handleToggleRejected(); + return; + } + if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(key)) { event.preventDefault(); @@ -477,6 +485,7 @@ export const useKeyboardShortcuts = ({ handleRightPanelSelect, handleRotate, handleSetColorLabel, + handleToggleRejected, handleToggleFullScreen, handleZoomChange, isFullScreen, From 332eb13e3aaf263970217825c73378947297a8f8 Mon Sep 17 00:00:00 2001 From: Floh <48927090+Flohhhhh@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:00:27 -0400 Subject: [PATCH 2/7] refine rejected thumbnail treatment --- src/components/panel/Filmstrip.tsx | 9 +++------ src/components/panel/MainLibrary.tsx | 18 +++++------------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/src/components/panel/Filmstrip.tsx b/src/components/panel/Filmstrip.tsx index e1459870b..f4b8d01e7 100644 --- a/src/components/panel/Filmstrip.tsx +++ b/src/components/panel/Filmstrip.tsx @@ -73,7 +73,7 @@ const FilmstripThumbnail = memo( const colorTag = tags?.find((t: string) => t.startsWith('color:'))?.substring(6); const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag); const isRejected = isRejectedRating(rating); - const contentOpacityClass = isRejected ? 'opacity-60' : ''; + const contentOpacityClass = isRejected ? 'opacity-40' : ''; const isVirtualCopy = path.includes('?vc='); const cleanPath = path.split('?')[0]; @@ -220,12 +220,9 @@ const FilmstripThumbnail = memo( <>
-
+
{isRejected && ( - <> - - Rejected - + )} {colorLabel && (
t.startsWith('color:'))?.substring(6); const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag); const isRejected = isRejectedRating(rating); - const contentOpacityClass = isRejected ? 'opacity-60' : ''; + const contentOpacityClass = isRejected ? 'opacity-40' : ''; const dateObj = new Date(modified > 1e11 ? modified : modified * 1000); const dateStr = @@ -1208,11 +1208,8 @@ function ListItem({
{isRejected && ( -
+
- - Rejected -
)} {!isRejected && rating > 0 && ( @@ -1331,7 +1328,7 @@ function Thumbnail({ const colorTag = tags?.find((t: string) => t.startsWith('color:'))?.substring(6); const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag); const isRejected = isRejectedRating(rating); - const contentOpacityClass = isRejected ? 'opacity-60' : ''; + const contentOpacityClass = isRejected ? 'opacity-40' : ''; return (
-
+
{isRejected && ( - <> - - - Rejected - - + )} {colorLabel && (
Date: Wed, 29 Apr 2026 12:21:10 -0400 Subject: [PATCH 3/7] Add library select-by batch selection dropdown --- src/App.tsx | 54 ++++++- src/components/panel/Filmstrip.tsx | 2 +- src/components/panel/MainLibrary.tsx | 221 ++++++++++++++++++++------- 3 files changed, 222 insertions(+), 55 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2fb164d71..e683ccbff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,7 +44,7 @@ import { } from 'lucide-react'; import TitleBar from './window/TitleBar'; import CommunityPage from './components/panel/CommunityPage'; -import MainLibrary, { ColumnWidths } from './components/panel/MainLibrary'; +import MainLibrary, { ColumnWidths, SelectByCriteria } from './components/panel/MainLibrary'; import FolderTree from './components/panel/FolderTree'; import Editor from './components/panel/Editor'; import Controls from './components/panel/right/ControlsPanel'; @@ -4195,6 +4195,57 @@ function App() { } }; + const handleSelectBy = useCallback( + (criteria: SelectByCriteria) => { + const matchingPaths = sortedImageList + .filter((image: ImageFile) => { + if (criteria.type === 'rating') { + const rating = imageRatings[image.path] ?? 0; + + if (criteria.mode === 'rejected') { + return rating === REJECTED_RATING; + } + + if (criteria.mode === 'unrejected') { + return rating !== REJECTED_RATING; + } + + if (criteria.mode === 'unrated') { + return rating === 0; + } + + const threshold = criteria.value ?? 0; + if (rating === REJECTED_RATING) { + return false; + } + return threshold === 5 ? rating === 5 : rating >= threshold; + } + + const imageColor = (image.tags || []).find((tag: string) => tag.startsWith('color:'))?.substring(6); + return imageColor === criteria.color; + }) + .map((image: ImageFile) => image.path); + + setMultiSelectedPaths(matchingPaths); + + if (selectedImage) { + if (matchingPaths.length > 0) { + handleImageSelect(matchingPaths[0]); + setSelectionAnchorPath(matchingPaths[0]); + } else { + handleBackToLibrary(); + setSelectionAnchorPath(null); + } + return; + } + + const nextActivePath = matchingPaths[0] ?? null; + setLibraryActivePath(nextActivePath); + setSelectionAnchorPath(nextActivePath); + }, + [sortedImageList, imageRatings, selectedImage, handleImageSelect, handleBackToLibrary], + ); + const handleRenameFiles = useCallback(async (paths: Array) => { if (paths && paths.length > 0) { setRenameTargetPaths(paths); @@ -5245,6 +5296,7 @@ function App() { onImportClick={() => handleImportClick(currentFolderPath as string)} onLibraryRefresh={handleLibraryRefresh} onOpenFolder={handleOpenFolder} + onSelectBy={handleSelectBy} onSettingsChange={handleSettingsChange} onThumbnailAspectRatioChange={setThumbnailAspectRatio} onThumbnailSizeChange={setThumbnailSize} diff --git a/src/components/panel/Filmstrip.tsx b/src/components/panel/Filmstrip.tsx index f4b8d01e7..15b8c4a94 100644 --- a/src/components/panel/Filmstrip.tsx +++ b/src/components/panel/Filmstrip.tsx @@ -73,7 +73,7 @@ const FilmstripThumbnail = memo( const colorTag = tags?.find((t: string) => t.startsWith('color:'))?.substring(6); const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag); const isRejected = isRejectedRating(rating); - const contentOpacityClass = isRejected ? 'opacity-40' : ''; + const contentOpacityClass = isRejected ? 'opacity-35' : ''; const isVirtualCopy = path.includes('?vc='); const cleanPath = path.split('?')[0]; diff --git a/src/components/panel/MainLibrary.tsx b/src/components/panel/MainLibrary.tsx index 2d8e2fb13..b95ab822b 100644 --- a/src/components/panel/MainLibrary.tsx +++ b/src/components/panel/MainLibrary.tsx @@ -16,6 +16,7 @@ import { RefreshCw, Settings, SlidersHorizontal, + SquareDashedMousePointer, Star as StarIcon, Search, Users, @@ -55,6 +56,10 @@ export interface ColumnWidths { color: number; } +export type SelectByCriteria = + | { type: 'rating'; mode: 'unrejected' | 'rejected' | 'unrated' | 'atLeast'; value?: number } + | { type: 'color'; color: string }; + interface DropdownMenuProps { buttonContent: any; buttonTitle: string; @@ -106,6 +111,7 @@ interface MainLibraryProps { onImportClick(): void; onLibraryRefresh(): void; onOpenFolder(): void; + onSelectBy(criteria: SelectByCriteria): void; onSettingsChange(settings: AppSettings): Promise; onThumbnailAspectRatioChange(aspectRatio: ThumbnailAspectRatio): void; onThumbnailSizeChange(size: ThumbnailSize): void; @@ -223,8 +229,51 @@ const rejectedStatusOptions: Array = [ { key: RejectedFilterStatus.RejectedOnly, label: 'Rejected Only' }, ]; +const selectByRatingOptions: Array<{ label: string; criteria: SelectByCriteria; starValue?: number }> = [ + { label: 'Unrejected', criteria: { type: 'rating', mode: 'unrejected' } }, + { label: 'Rejected', criteria: { type: 'rating', mode: 'rejected' } }, + { label: 'Unrated', criteria: { type: 'rating', mode: 'unrated' } }, + { label: '1 star & up', criteria: { type: 'rating', mode: 'atLeast', value: 1 }, starValue: 1 }, + { label: '2 star & up', criteria: { type: 'rating', mode: 'atLeast', value: 2 }, starValue: 2 }, + { label: '3 star & up', criteria: { type: 'rating', mode: 'atLeast', value: 3 }, starValue: 3 }, + { label: '4 star & up', criteria: { type: 'rating', mode: 'atLeast', value: 4 }, starValue: 4 }, + { label: '5 star only', criteria: { type: 'rating', mode: 'atLeast', value: 5 }, starValue: 5 }, +]; + const isRejectedRating = (rating: number) => rating === REJECTED_RATING; +const matchesSelectByCriteria = ( + image: ImageFile, + criteria: SelectByCriteria, + imageRatings: Record, +): boolean => { + if (criteria.type === 'rating') { + const rating = imageRatings[image.path] ?? 0; + + if (criteria.mode === 'rejected') { + return rating === REJECTED_RATING; + } + + if (criteria.mode === 'unrejected') { + return rating !== REJECTED_RATING; + } + + if (criteria.mode === 'unrated') { + return rating === 0; + } + + const threshold = criteria.value ?? 0; + if (rating === REJECTED_RATING) { + return false; + } + + return threshold === 5 ? rating === 5 : rating >= threshold; + } + + const imageColor = (image.tags || []).find((tag: string) => tag.startsWith('color:'))?.substring(6); + return imageColor === criteria.color; +}; + const thumbnailSizeOptions: Array = [ { id: ThumbnailSize.Small, label: 'Small', size: 160 }, { id: ThumbnailSize.Medium, label: 'Medium', size: 240 }, @@ -334,9 +383,8 @@ function ListHeader({ return (
sortKey && onSortChange(sortKey)} > onSelectSize(option.id)} role="menuitem" @@ -747,9 +794,8 @@ function ThumbnailAspectRatioOptions({ selectedAspectRatio, onSelectAspectRatio const isSelected = selectedAspectRatio === option.id; return ( + ))} + + + Color Label + + {COLOR_LABELS.map((label: Color) => ( + + ))} +
+ + ); +} + function ListItem({ data, isActive, @@ -1108,7 +1228,7 @@ function ListItem({ const colorTag = tags?.find((t: string) => t.startsWith('color:'))?.substring(6); const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag); const isRejected = isRejectedRating(rating); - const contentOpacityClass = isRejected ? 'opacity-40' : ''; + const contentOpacityClass = isRejected ? 'opacity-35' : ''; const dateObj = new Date(modified > 1e11 ? modified : modified * 1000); const dateStr = @@ -1148,9 +1268,8 @@ function ListItem({ > {baseName} t.startsWith('color:'))?.substring(6); const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag); const isRejected = isRejectedRating(rating); - const contentOpacityClass = isRejected ? 'opacity-40' : ''; + const contentOpacityClass = isRejected ? 'opacity-35' : ''; return (
{path.split(/[\\/]/).pop()} ) : ( - `A blazingly fast, GPU-accelerated RAW image editor. ${ - isAndroid ? 'Open the library to begin.' : 'Open a folder to begin.' + `A blazingly fast, GPU-accelerated RAW image editor. ${isAndroid ? 'Open the library to begin.' : 'Open a folder to begin.' }` )} @@ -2018,9 +2136,8 @@ export default function MainLibrary({ )}
- ))} +
+ {COLOR_LABELS.map((label: Color) => { + const isDisabled = (optionCounts.get(label.name) || 0) === 0; + + return ( + + ); + })} +
); From 0ba5831d2f44d09a986da27facf79558c1b028c7 Mon Sep 17 00:00:00 2001 From: Floh <48927090+Flohhhhh@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:37:37 -0400 Subject: [PATCH 7/7] Fix mixed-selection reject toggle --- src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index ef4c7b160..9c9f1e2db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2898,8 +2898,8 @@ function App() { return; } - const currentRating = imageRatings[pathsToRate[0]] || 0; - const finalRating = currentRating === REJECTED_RATING ? 0 : REJECTED_RATING; + const allRejected = pathsToRate.every((path: string) => (imageRatings[path] ?? 0) === REJECTED_RATING); + const finalRating = allRejected ? 0 : REJECTED_RATING; applyRatingToPaths(pathsToRate, finalRating); },