Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion src/components/panel/BottomBar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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 (
<div className={clsx('flex items-center gap-1', disabled && 'cursor-not-allowed')}>
<button
className="disabled:cursor-not-allowed"
disabled={disabled}
onClick={() => !disabled && onSetFlag('picked')}
data-tooltip={disabled ? t('ui.bottomBar.tooltips.selectToRate') : t('ui.bottomBar.tooltips.flagPick')}
>
<Flag
size={18}
className={clsx(
'transition-colors duration-150',
disabled
? 'text-text-secondary opacity-40'
: flag === 'picked'
? 'fill-green-500 text-green-500'
: 'text-text-secondary hover:text-green-500',
)}
/>
</button>
<button
className="disabled:cursor-not-allowed"
disabled={disabled}
onClick={() => !disabled && onSetFlag('rejected')}
data-tooltip={disabled ? t('ui.bottomBar.tooltips.selectToRate') : t('ui.bottomBar.tooltips.flagReject')}
>
<FlagOff
size={18}
className={clsx(
'transition-colors duration-150',
disabled
? 'text-text-secondary opacity-40'
: flag === 'rejected'
? 'fill-red-500 text-red-500'
: 'text-text-secondary hover:text-red-500',
)}
/>
</button>
</div>
);
};

export default function BottomBar({
filmstripHeight,
imageList = [],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -279,6 +340,8 @@ export default function BottomBar({
<div className="flex items-center gap-4">
<StarRating rating={rating} onRate={onRate} disabled={isRatingDisabled} />
<div className="h-5 w-px bg-surface"></div>
<FlagButtons flag={currentFlag} onSetFlag={handleSetFlag} disabled={isRatingDisabled} />
<div className="h-5 w-px bg-surface"></div>
<div className="flex items-center gap-2">
<button
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"
Expand Down
44 changes: 43 additions & 1 deletion src/components/panel/library/LibraryHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
ChevronUp,
ChevronDown,
HelpCircle,
Flag,
FlagOff,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useShallow } from 'zustand/react/shallow';
Expand All @@ -18,6 +20,7 @@ import {
FilterCriteria,
RawStatus,
EditedStatus,
FlagStatus,
LibraryViewMode,
SortCriteria,
SortDirection,
Expand Down Expand Up @@ -332,7 +335,8 @@ export function ViewOptionsDropdown({
const isFilterActive =
filterCriteria.rating !== 0 ||
(filterCriteria.rawStatus && filterCriteria.rawStatus !== RawStatus.All) ||
(filterCriteria.colors && filterCriteria.colors.length > 0);
(filterCriteria.colors && filterCriteria.colors.length > 0) ||
(filterCriteria.flagStatus && filterCriteria.flagStatus !== FlagStatus.All);

const [lastClickedColor, setLastClickedColor] = useState<string | null>(null);
const allColors = useMemo(() => [...COLOR_LABELS, { name: 'none', color: '#9ca3af' }], []);
Expand Down Expand Up @@ -655,6 +659,44 @@ export function ViewOptionsDropdown({
);
})}
</div>

<div>
<Text as="div" variant={TextVariants.small} weight={TextWeights.semibold} className="px-3 py-2 uppercase">
{t('library.header.viewOptions.filterByFlag', 'Filter by Flag')}
</Text>
{[
{ 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: <Flag size={14} className="text-green-400" /> },
{ key: FlagStatus.RejectedOnly, label: t('library.filters.flag.rejectedOnly', 'Rejected Only'), icon: <FlagOff size={14} className="text-red-400" /> },
{ key: FlagStatus.UnflaggedOnly, label: t('library.filters.flag.unflaggedOnly', 'Unflagged Only'), icon: null },
].map((option) => {
const isSelected = (filterCriteria.flagStatus || FlagStatus.All) === option.key;
return (
<button
className={`w-full text-left px-3 py-2 rounded-md flex items-center justify-between transition-colors duration-150 ${
isSelected ? 'bg-card-active' : 'hover:bg-bg-primary'
}`}
key={option.key}
onClick={() =>
setFilterCriteria({ flagStatus: option.key as FlagStatus })
}
role="menuitem"
>
<span className="flex items-center gap-2">
{option.icon}
<Text
variant={TextVariants.label}
color={TextColors.primary}
weight={isSelected ? TextWeights.semibold : TextWeights.normal}
>
{option.label}
</Text>
</span>
{isSelected && <Check size={16} className={TEXT_COLOR_KEYS[TextColors.primary]} />}
</button>
);
})}
</div>
</div>

<div className="py-2"></div>
Expand Down
24 changes: 23 additions & 1 deletion src/components/panel/library/LibraryItems.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -194,6 +196,26 @@ const ThumbnailComponent = ({
)}
/>

<AnimatePresence initial={false}>
{flagTag && (
<motion.div
key="flag-badge"
initial={{ opacity: 0, scale: 0.8, y: -5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: -5 }}
transition={{ duration: 0.25, type: 'spring', bounce: 0.3 }}
className="absolute top-2 left-2 z-10 pointer-events-none rounded-full p-1 backdrop-blur-md shadow-md"
style={{ backgroundColor: flagTag === 'picked' ? 'rgba(34,197,94,0.25)' : 'rgba(239,68,68,0.25)' }}
>
{flagTag === 'picked' ? (
<Flag size={11} className="text-green-400 fill-green-400" />
) : (
<FlagOff size={11} className="text-red-400" />
)}
</motion.div>
)}
</AnimatePresence>

<div className="absolute top-1.5 right-1.5 flex items-center justify-end z-10 pointer-events-none">
<div
className={clsx(
Expand Down
10 changes: 10 additions & 0 deletions src/components/ui/AppProperties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const GLOBAL_KEYS = [
'p',
'i',
'e',
'x',
'u',
'0',
'1',
'2',
Expand Down Expand Up @@ -223,11 +225,19 @@ export const EditedStatus = {

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

export enum FlagStatus {
All = 'all',
FlaggedOnly = 'flaggedOnly',
RejectedOnly = 'rejectedOnly',
UnflaggedOnly = 'unflaggedOnly',
}

export interface FilterCriteria {
colors: Array<string>;
rating: number;
rawStatus: RawStatus;
editedStatus?: EditedStatus;
flagStatus?: FlagStatus;
}

export interface Folder {
Expand Down
22 changes: 21 additions & 1 deletion src/hooks/useAppContextMenus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
() => [
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 23 additions & 1 deletion src/hooks/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -571,5 +592,6 @@ export const useKeyboardShortcuts = ({
handlePasteAdjustments,
handleRate,
handleSetColorLabel,
handleSetFlag,
]);
};
40 changes: 40 additions & 0 deletions src/hooks/useLibraryActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -387,6 +426,7 @@ export function useLibraryActions(handleImageSelect?: (path: string) => void) {
return {
handleRate,
handleSetColorLabel,
handleSetFlag,
handleTagsChanged,
handleUpdateExif,
handleClearSelection,
Expand Down
Loading