From 07094d5842684065229bee73d4951d3339fa86b6 Mon Sep 17 00:00:00 2001 From: PritamP20 Date: Tue, 25 Nov 2025 02:23:32 +0530 Subject: [PATCH] image batch operation --- README.md | 16 +- backend/app/database/images.py | 94 ++++++++ backend/app/routes/images.py | 92 ++++++++ backend/app/schemas/batch.py | 23 ++ docs/backend/backend_python/openapi.json | 222 ++++++++++++++++++ frontend/jest.setup.ts | 12 + frontend/package-lock.json | 45 ++-- frontend/package.json | 1 + frontend/src/app/store.ts | 2 + .../src/components/Batch/BatchTagDialog.tsx | 111 +++++++++ .../src/components/Batch/BatchToolbar.tsx | 211 +++++++++++++++++ frontend/src/components/Media/ImageCard.tsx | 42 +++- .../components/Navigation/Navbar/Navbar.tsx | 16 +- frontend/src/components/ui/checkbox.tsx | 28 +++ frontend/src/hooks/use-toast.ts | 19 ++ frontend/src/hooks/useBatchKeyboard.ts | 44 ++++ frontend/src/pages/AITagging/AITagging.tsx | 9 + frontend/src/pages/Home/Home.tsx | 9 + frontend/src/pages/Home/MyFav.tsx | 9 +- frontend/src/store/slices/selectionSlice.ts | 54 +++++ frontend/src/utils/config.ts | 3 + 21 files changed, 1036 insertions(+), 26 deletions(-) create mode 100644 backend/app/schemas/batch.py create mode 100644 frontend/src/components/Batch/BatchTagDialog.tsx create mode 100644 frontend/src/components/Batch/BatchToolbar.tsx create mode 100644 frontend/src/components/ui/checkbox.tsx create mode 100644 frontend/src/hooks/use-toast.ts create mode 100644 frontend/src/hooks/useBatchKeyboard.ts create mode 100644 frontend/src/store/slices/selectionSlice.ts create mode 100644 frontend/src/utils/config.ts diff --git a/README.md b/README.md index 595889480..cc0ef1f7a 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,15 @@ Handles file system operations and provides a secure bridge between the frontend ## Features -- Smart tagging of photos based on detected objects, faces, and their recognition -- Traditional gallery features of album management -- Advanced image analysis with object detection and facial recognition -- Privacy-focused design with offline functionality -- Efficient data handling and parallel processing -- Smart search and retrieval -- Cross-platform compatibility +- **Smart Tagging**: Automatic photo tagging based on detected objects, faces, and their recognition +- **Batch Operations**: Select multiple images and perform bulk actions (delete, tag, move to album, export) +- **Album Management**: Traditional gallery features for organizing your photos +- **Advanced Image Analysis**: Object detection and facial recognition powered by AI +- **Privacy-Focused**: Offline functionality with all processing done locally +- **Efficient Processing**: Parallel processing for handling large photo collections +- **Smart Search**: Quick retrieval with advanced search capabilities +- **Cross-Platform**: Works on Windows, macOS, and Linux +- **Keyboard Shortcuts**: Ctrl+A to toggle select/deselect all, Escape to deselect (works on all platforms including Mac) ## Technical Stack diff --git a/backend/app/database/images.py b/backend/app/database/images.py index ec9541a56..8b68543b3 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -419,3 +419,97 @@ def db_toggle_image_favourite_status(image_id: str) -> bool: return False finally: conn.close() + + + +def db_delete_image(image_id: ImageId) -> bool: + """ + Delete a single image from the database by its ID. + This will also delete associated records in image_classes due to CASCADE. + + Args: + image_id: ID of the image to delete + + Returns: + True if deletion was successful, False otherwise + """ + conn = _connect() + cursor = conn.cursor() + + try: + cursor.execute("DELETE FROM images WHERE id = ?", (image_id,)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error deleting image {image_id}: {e}") + conn.rollback() + return False + finally: + conn.close() + + +def db_add_tags_to_image(image_id: ImageId, tags: List[str]) -> bool: + """ + Add multiple tags to an image. + + Args: + image_id: ID of the image to tag + tags: List of tag names to add + + Returns: + True if tagging was successful, False otherwise + """ + if not tags: + return True + + conn = _connect() + cursor = conn.cursor() + + try: + # First, verify the image exists + cursor.execute("SELECT id FROM images WHERE id = ?", (image_id,)) + if not cursor.fetchone(): + logger.warning(f"Image {image_id} not found") + return False + + # Get or create class IDs for each tag + class_ids = [] + for tag in tags: + # Check if tag exists in mappings + cursor.execute("SELECT class_id FROM mappings WHERE name = ?", (tag,)) + result = cursor.fetchone() + + if result: + class_ids.append(result[0]) + else: + # Create new mapping for this tag + cursor.execute( + "INSERT INTO mappings (name) VALUES (?)", + (tag,) + ) + class_ids.append(cursor.lastrowid) + + # Insert image-class pairs + for class_id in class_ids: + cursor.execute( + """ + INSERT OR IGNORE INTO image_classes (image_id, class_id) + VALUES (?, ?) + """, + (image_id, class_id), + ) + + # Mark image as tagged + cursor.execute( + "UPDATE images SET isTagged = 1 WHERE id = ?", + (image_id,) + ) + + conn.commit() + return True + except Exception as e: + logger.error(f"Error adding tags to image {image_id}: {e}") + conn.rollback() + return False + finally: + conn.close() diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index 2e40cd825..9dc137f41 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -128,3 +128,95 @@ class ImageInfoResponse(BaseModel): isTagged: bool isFavourite: bool tags: Optional[List[str]] = None + + +# Batch Operations +from app.schemas.batch import ( + BatchDeleteRequest, + BatchTagRequest, + BatchMoveRequest, + BatchOperationResponse +) +from app.database.images import db_delete_image, db_add_tags_to_image + + +@router.delete("/batch-delete", response_model=BatchOperationResponse) +def batch_delete_images(request: BatchDeleteRequest): + """Delete multiple images""" + processed = 0 + failed = 0 + errors = [] + + for image_id in request.image_ids: + try: + success = db_delete_image(image_id) + if success: + processed += 1 + else: + failed += 1 + errors.append(f"Image {image_id} not found") + except Exception as e: + failed += 1 + errors.append(f"Failed to delete {image_id}: {str(e)}") + logger.error(f"Error deleting image {image_id}: {e}") + + return BatchOperationResponse( + success=failed == 0, + processed=processed, + failed=failed, + errors=errors + ) + + +@router.post("/batch-tag", response_model=BatchOperationResponse) +def batch_tag_images(request: BatchTagRequest): + """Add tags to multiple images""" + processed = 0 + failed = 0 + errors = [] + + for image_id in request.image_ids: + try: + success = db_add_tags_to_image(image_id, request.tags) + if success: + processed += 1 + else: + failed += 1 + errors.append(f"Image {image_id} not found") + except Exception as e: + failed += 1 + errors.append(f"Failed to tag {image_id}: {str(e)}") + logger.error(f"Error tagging image {image_id}: {e}") + + return BatchOperationResponse( + success=failed == 0, + processed=processed, + failed=failed, + errors=errors + ) + + +@router.post("/batch-move", response_model=BatchOperationResponse) +def batch_move_to_album(request: BatchMoveRequest): + """Move multiple images to an album""" + processed = 0 + failed = 0 + errors = [] + + # TODO: Implement album functionality + for image_id in request.image_ids: + try: + # Placeholder for album move functionality + # success = db_add_image_to_album(image_id, request.album_id) + processed += 1 + except Exception as e: + failed += 1 + errors.append(f"Failed to move {image_id}: {str(e)}") + logger.error(f"Error moving image {image_id}: {e}") + + return BatchOperationResponse( + success=failed == 0, + processed=processed, + failed=failed, + errors=errors + ) diff --git a/backend/app/schemas/batch.py b/backend/app/schemas/batch.py new file mode 100644 index 000000000..809eba31c --- /dev/null +++ b/backend/app/schemas/batch.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from typing import List + +class BatchDeleteRequest(BaseModel): + """Request model for batch delete operation""" + image_ids: List[str] + +class BatchTagRequest(BaseModel): + """Request model for batch tag operation""" + image_ids: List[str] + tags: List[str] + +class BatchMoveRequest(BaseModel): + """Request model for batch move to album operation""" + image_ids: List[str] + album_id: str + +class BatchOperationResponse(BaseModel): + """Response model for batch operations""" + success: bool + processed: int + failed: int + errors: List[str] = [] diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index a29e7c4f1..d4995b4a3 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -926,6 +926,132 @@ } } }, + "/images/batch-delete": { + "delete": { + "tags": [ + "Images" + ], + "summary": "Batch Delete Images", + "description": "Delete multiple images", + "operationId": "batch_delete_images_images_batch_delete_delete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchDeleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchOperationResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/images/batch-tag": { + "post": { + "tags": [ + "Images" + ], + "summary": "Batch Tag Images", + "description": "Add tags to multiple images", + "operationId": "batch_tag_images_images_batch_tag_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchTagRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchOperationResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/images/batch-move": { + "post": { + "tags": [ + "Images" + ], + "summary": "Batch Move To Album", + "description": "Move multiple images to an album", + "operationId": "batch_move_to_album_images_batch_move_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchMoveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchOperationResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/face-clusters/{cluster_id}": { "put": { "tags": [ @@ -1435,6 +1561,102 @@ ], "title": "Album" }, + "BatchDeleteRequest": { + "properties": { + "image_ids": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Image Ids" + } + }, + "type": "object", + "required": [ + "image_ids" + ], + "title": "BatchDeleteRequest", + "description": "Request model for batch delete operation" + }, + "BatchMoveRequest": { + "properties": { + "image_ids": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Image Ids" + }, + "album_id": { + "type": "string", + "title": "Album Id" + } + }, + "type": "object", + "required": [ + "image_ids", + "album_id" + ], + "title": "BatchMoveRequest", + "description": "Request model for batch move to album operation" + }, + "BatchOperationResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success" + }, + "processed": { + "type": "integer", + "title": "Processed" + }, + "failed": { + "type": "integer", + "title": "Failed" + }, + "errors": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Errors", + "default": [] + } + }, + "type": "object", + "required": [ + "success", + "processed", + "failed" + ], + "title": "BatchOperationResponse", + "description": "Response model for batch operations" + }, + "BatchTagRequest": { + "properties": { + "image_ids": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Image Ids" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Tags" + } + }, + "type": "object", + "required": [ + "image_ids", + "tags" + ], + "title": "BatchTagRequest", + "description": "Request model for batch tag operation" + }, "ClusterMetadata": { "properties": { "cluster_id": { diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index 8e90c0a43..9a182a8a5 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -16,3 +16,15 @@ class ResizeObserver { } (global as any).ResizeObserver = ResizeObserver; + +// Mock import.meta for Jest +Object.defineProperty(global, 'import', { + value: { + meta: { + env: { + VITE_BACKEND_URL: 'http://localhost:8000', + }, + }, + }, + writable: true, +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e1e1ddd5f..f9932e4a6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@radix-ui/react-aspect-ratio": "^1.1.7", "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", @@ -3423,6 +3424,36 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -14476,20 +14507,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0a53f1b8d..4666d3ef0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "dependencies": { "@radix-ui/react-aspect-ratio": "^1.1.7", "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", diff --git a/frontend/src/app/store.ts b/frontend/src/app/store.ts index 7252274a6..ef70e6734 100644 --- a/frontend/src/app/store.ts +++ b/frontend/src/app/store.ts @@ -6,6 +6,7 @@ import imageReducer from '@/features/imageSlice'; import faceClustersReducer from '@/features/faceClustersSlice'; import infoDialogReducer from '@/features/infoDialogSlice'; import folderReducer from '@/features/folderSlice'; +import selectionReducer from '@/store/slices/selectionSlice'; export const store = configureStore({ reducer: { @@ -16,6 +17,7 @@ export const store = configureStore({ infoDialog: infoDialogReducer, folders: folderReducer, search: searchReducer, + selection: selectionReducer, }, }); // Infer the `RootState` and `AppDispatch` types from the store itself diff --git a/frontend/src/components/Batch/BatchTagDialog.tsx b/frontend/src/components/Batch/BatchTagDialog.tsx new file mode 100644 index 000000000..f27c62ff8 --- /dev/null +++ b/frontend/src/components/Batch/BatchTagDialog.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '../ui/dialog'; +import { Input } from '../ui/input'; +import { Button } from '../ui/button'; +import { Badge } from '../ui/badge'; +import { X } from 'lucide-react'; + +interface BatchTagDialogProps { + isOpen: boolean; + onClose: () => void; + selectedCount: number; + onConfirm: (tags: string[]) => Promise; +} + +export const BatchTagDialog = ({ + isOpen, + onClose, + selectedCount, + onConfirm, +}: BatchTagDialogProps) => { + const [tags, setTags] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + + const handleAddTag = () => { + if (inputValue.trim() && !tags.includes(inputValue.trim())) { + setTags([...tags, inputValue.trim()]); + setInputValue(''); + } + }; + + const handleRemoveTag = (tagToRemove: string) => { + setTags(tags.filter((tag) => tag !== tagToRemove)); + }; + + const handleConfirm = async () => { + setIsProcessing(true); + try { + await onConfirm(tags); + setTags([]); + onClose(); + } catch (error) { + console.error('Failed to apply tags:', error); + } finally { + setIsProcessing(false); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTag(); + } + }; + + return ( + + + + Add Tags to {selectedCount} Images + + +
+
+ setInputValue(e.target.value)} + onKeyPress={handleKeyPress} + disabled={isProcessing} + /> + +
+ + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + handleRemoveTag(tag)} + /> + + ))} +
+ )} + +
+ + +
+
+
+
+ ); +}; diff --git a/frontend/src/components/Batch/BatchToolbar.tsx b/frontend/src/components/Batch/BatchToolbar.tsx new file mode 100644 index 000000000..5400913a5 --- /dev/null +++ b/frontend/src/components/Batch/BatchToolbar.tsx @@ -0,0 +1,211 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from '@/app/store'; +import { + deselectAllImages, + setSelectionMode, + removeFromSelection, +} from '@/store/slices/selectionSlice'; +import { Button } from '../ui/button'; +import { Trash2, Tag, FolderInput, Download, X } from 'lucide-react'; +import { useState } from 'react'; +import { BatchTagDialog } from './BatchTagDialog'; +import { useToast } from '@/hooks/use-toast'; +import { getBackendUrl } from '@/utils/config'; + +export const BatchToolbar = () => { + const dispatch = useDispatch(); + const { toast } = useToast(); + const { selectedImageIds } = useSelector( + (state: RootState) => state.selection + ); + const [isProcessing, setIsProcessing] = useState(false); + const [showTagDialog, setShowTagDialog] = useState(false); + + const handleBatchDelete = async () => { + const count = selectedImageIds.length; + if ( + !window.confirm( + `Are you sure you want to delete ${count} image${count > 1 ? 's' : ''}? This action cannot be undone.` + ) + ) { + return; + } + + setIsProcessing(true); + try { + const backendUrl = getBackendUrl(); + const response = await fetch( + `${backendUrl}/images/batch-delete`, + { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image_ids: selectedImageIds }), + } + ); + + if (!response.ok) { + throw new Error('Failed to delete images'); + } + + const result = await response.json(); + + toast({ + title: 'Success', + description: `Deleted ${result.processed} image${result.processed > 1 ? 's' : ''}`, + variant: 'default', + }); + + dispatch(removeFromSelection(selectedImageIds)); + dispatch(deselectAllImages()); + + // Refresh the page to update the gallery + window.location.reload(); + } catch (error) { + console.error('Batch delete failed:', error); + toast({ + title: 'Error', + description: 'Failed to delete images. Please try again.', + variant: 'destructive', + }); + } finally { + setIsProcessing(false); + } + }; + + const handleBatchTag = async (tags: string[]) => { + setIsProcessing(true); + try { + const backendUrl = getBackendUrl(); + const response = await fetch( + `${backendUrl}/images/batch-tag`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image_ids: selectedImageIds, tags }), + } + ); + + if (!response.ok) { + throw new Error('Failed to tag images'); + } + + const result = await response.json(); + + toast({ + title: 'Success', + description: `Tagged ${result.processed} image${result.processed > 1 ? 's' : ''}`, + variant: 'default', + }); + + dispatch(deselectAllImages()); + } catch (error) { + console.error('Batch tag failed:', error); + toast({ + title: 'Error', + description: 'Failed to tag images. Please try again.', + variant: 'destructive', + }); + throw error; + } finally { + setIsProcessing(false); + } + }; + + const handleBatchExport = async () => { + toast({ + title: 'Coming Soon', + description: 'Batch export feature is under development.', + variant: 'default', + }); + }; + + const handleBatchMove = async () => { + toast({ + title: 'Coming Soon', + description: 'Batch move to album feature is under development.', + variant: 'default', + }); + }; + + if (selectedImageIds.length === 0) return null; + + return ( + <> +
+
+ + {selectedImageIds.length} selected + + +
+ + + + + + + + + +
+ + +
+
+ + setShowTagDialog(false)} + selectedCount={selectedImageIds.length} + onConfirm={handleBatchTag} + /> + + ); +}; diff --git a/frontend/src/components/Media/ImageCard.tsx b/frontend/src/components/Media/ImageCard.tsx index 47763f022..326040079 100644 --- a/frontend/src/components/Media/ImageCard.tsx +++ b/frontend/src/components/Media/ImageCard.tsx @@ -7,6 +7,10 @@ import { Image } from '@/types/Media'; import { ImageTags } from './ImageTags'; import { convertFileSrc } from '@tauri-apps/api/core'; import { useToggleFav } from '@/hooks/useToggleFav'; +import { useDispatch, useSelector } from 'react-redux'; +import { toggleImageSelection } from '@/store/slices/selectionSlice'; +import { RootState } from '@/app/store'; +import { Checkbox } from '@/components/ui/checkbox'; interface ImageCardViewProps { image: Image; @@ -24,30 +28,62 @@ export function ImageCard({ showTags = true, onClick, }: ImageCardViewProps) { + const dispatch = useDispatch(); const [isImageHovered, setIsImageHovered] = useState(false); // Default to empty array if no tags are provided const tags = image.tags || []; const { toggleFavourite } = useToggleFav(); + + const { selectedImageIds, isSelectionMode } = useSelector( + (state: RootState) => state.selection + ); + const isImageSelected = selectedImageIds.includes(image.id); const handleToggleFavourite = useCallback(() => { if (image?.id) { toggleFavourite(image.id); } }, [image, toggleFavourite]); + + const handleCheckboxClick = (e: React.MouseEvent) => { + e.stopPropagation(); + dispatch(toggleImageSelection(image.id)); + }; + + const handleCardClick = () => { + if (isSelectionMode) { + dispatch(toggleImageSelection(image.id)); + } else if (onClick) { + onClick(); + } + }; return (
setIsImageHovered(true)} onMouseLeave={() => setIsImageHovered(false)} - onClick={onClick} + onClick={handleCardClick} >
+ {/* Selection checkbox in selection mode */} + {isSelectionMode && ( +
+ +
+ )} + {/* Selection tick mark */} - {isSelected && ( + {isSelected && !isSelectionMode && (
diff --git a/frontend/src/components/Navigation/Navbar/Navbar.tsx b/frontend/src/components/Navigation/Navbar/Navbar.tsx index c565b7f6d..90cbcdea9 100644 --- a/frontend/src/components/Navigation/Navbar/Navbar.tsx +++ b/frontend/src/components/Navigation/Navbar/Navbar.tsx @@ -1,11 +1,14 @@ import { Input } from '@/components/ui/input'; import { ThemeSelector } from '@/components/ThemeToggle'; -import { Search } from 'lucide-react'; +import { Search, CheckSquare } from 'lucide-react'; import { useDispatch, useSelector } from 'react-redux'; import { selectAvatar, selectName } from '@/features/onboardingSelectors'; import { clearSearch } from '@/features/searchSlice'; import { convertFileSrc } from '@tauri-apps/api/core'; import { FaceSearchDialog } from '@/components/Dialog/FaceSearchDialog'; +import { Button } from '@/components/ui/button'; +import { setSelectionMode } from '@/store/slices/selectionSlice'; +import { RootState } from '@/app/store'; export function Navbar() { const userName = useSelector(selectName); @@ -14,6 +17,8 @@ export function Navbar() { const searchState = useSelector((state: any) => state.search); const isSearchActive = searchState.active; const queryImage = searchState.queryImage; + + const { isSelectionMode } = useSelector((state: RootState) => state.selection); const dispatch = useDispatch(); return ( @@ -77,6 +82,15 @@ export function Navbar() { {/* Right Side */}
+
diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 000000000..e9f549247 --- /dev/null +++ b/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { Check } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/frontend/src/hooks/use-toast.ts b/frontend/src/hooks/use-toast.ts new file mode 100644 index 000000000..086ba7d30 --- /dev/null +++ b/frontend/src/hooks/use-toast.ts @@ -0,0 +1,19 @@ +export const useToast = () => { + const toast = ({ + title, + description, + variant = 'default', + }: { + title: string; + description: string; + variant?: 'default' | 'destructive'; + }) => { + if (variant === 'destructive') { + alert(`❌ ${title}\n${description}`); + } else { + alert(`✅ ${title}\n${description}`); + } + }; + + return { toast }; +}; diff --git a/frontend/src/hooks/useBatchKeyboard.ts b/frontend/src/hooks/useBatchKeyboard.ts new file mode 100644 index 000000000..6a1810b95 --- /dev/null +++ b/frontend/src/hooks/useBatchKeyboard.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from '@/app/store'; +import { + selectAllImages, + deselectAllImages, +} from '@/store/slices/selectionSlice'; + +export const useBatchKeyboard = (allImageIds: string[]) => { + const dispatch = useDispatch(); + const { isSelectionMode } = useSelector( + (state: RootState) => state.selection + ); + + const { selectedImageIds } = useSelector( + (state: RootState) => state.selection + ); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isSelectionMode) return; + + // Ctrl+A: Toggle between select all and deselect all + if (e.ctrlKey && e.key === 'a') { + e.preventDefault(); + // If all images are selected, deselect all; otherwise select all + if (selectedImageIds.length === allImageIds.length && allImageIds.length > 0) { + dispatch(deselectAllImages()); + } else { + dispatch(selectAllImages(allImageIds)); + } + } + + // Escape: Deselect all + if (e.key === 'Escape') { + e.preventDefault(); + dispatch(deselectAllImages()); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isSelectionMode, allImageIds, selectedImageIds, dispatch]); +}; diff --git a/frontend/src/pages/AITagging/AITagging.tsx b/frontend/src/pages/AITagging/AITagging.tsx index 187bda3df..d870c4d54 100644 --- a/frontend/src/pages/AITagging/AITagging.tsx +++ b/frontend/src/pages/AITagging/AITagging.tsx @@ -13,6 +13,8 @@ import { } from '@/components/Media/ChronologicalGallery'; import TimelineScrollbar from '@/components/Timeline/TimelineScrollbar'; import { EmptyAITaggingState } from '@/components/EmptyStates/EmptyAITaggingState'; +import { BatchToolbar } from '@/components/Batch/BatchToolbar'; +import { useBatchKeyboard } from '@/hooks/useBatchKeyboard'; export const AITagging = () => { const dispatch = useDispatch(); @@ -41,6 +43,10 @@ export const AITagging = () => { } }, [imagesData, imagesSuccess, imagesError, imagesLoading, dispatch]); + // Enable batch keyboard shortcuts + const allImageIds = taggedImages.map((img) => img.id); + useBatchKeyboard(allImageIds); + return (
{ className="absolute top-0 right-0 h-full w-4" /> )} + + {/* Batch Operations Toolbar */} +
); }; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 83c9e5c83..7678582bd 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -13,6 +13,8 @@ import { fetchAllImages } from '@/api/api-functions'; import { RootState } from '@/app/store'; import { EmptyGalleryState } from '@/components/EmptyStates/EmptyGalleryState'; import { useMutationFeedback } from '@/hooks/useMutationFeedback'; +import { BatchToolbar } from '@/components/Batch/BatchToolbar'; +import { useBatchKeyboard } from '@/hooks/useBatchKeyboard'; export const Home = () => { const dispatch = useDispatch(); @@ -50,6 +52,10 @@ export const Home = () => { ? `Face Search Results (${images.length} found)` : 'Image Gallery'; + // Enable batch keyboard shortcuts + const allImageIds = images.map((img) => img.id); + useBatchKeyboard(allImageIds); + return (
{/* Gallery Section */} @@ -78,6 +84,9 @@ export const Home = () => { className="absolute top-0 right-0 h-full w-4" /> )} + + {/* Batch Operations Toolbar */} +
); }; diff --git a/frontend/src/pages/Home/MyFav.tsx b/frontend/src/pages/Home/MyFav.tsx index 2afcb70e8..25c893f7c 100644 --- a/frontend/src/pages/Home/MyFav.tsx +++ b/frontend/src/pages/Home/MyFav.tsx @@ -14,6 +14,8 @@ import { RootState } from '@/app/store'; import { EmptyGalleryState } from '@/components/EmptyStates/EmptyGalleryState'; import { Heart } from 'lucide-react'; import { useMutationFeedback } from '@/hooks/useMutationFeedback'; +import { BatchToolbar } from '@/components/Batch/BatchToolbar'; +import { useBatchKeyboard } from '@/hooks/useBatchKeyboard'; export const MyFav = () => { const dispatch = useDispatch(); @@ -57,6 +59,10 @@ export const MyFav = () => { ? `Face Search Results (${images.length} found)` : 'Favourite Image Gallery'; + // Enable batch keyboard shortcuts + const allImageIds = favouriteImages.map((img) => img.id); + useBatchKeyboard(allImageIds); + if (favouriteImages.length === 0) { return (
@@ -109,7 +115,8 @@ export const MyFav = () => { /> )} - {/* Media viewer modal */} + {/* Batch Operations Toolbar */} +
); }; diff --git a/frontend/src/store/slices/selectionSlice.ts b/frontend/src/store/slices/selectionSlice.ts new file mode 100644 index 000000000..55121f7cc --- /dev/null +++ b/frontend/src/store/slices/selectionSlice.ts @@ -0,0 +1,54 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface SelectionState { + selectedImageIds: string[]; + isSelectionMode: boolean; +} + +const initialState: SelectionState = { + selectedImageIds: [], + isSelectionMode: false, +}; + +const selectionSlice = createSlice({ + name: 'selection', + initialState, + reducers: { + toggleImageSelection: (state, action: PayloadAction) => { + const imageId = action.payload; + const index = state.selectedImageIds.indexOf(imageId); + if (index > -1) { + state.selectedImageIds.splice(index, 1); + } else { + state.selectedImageIds.push(imageId); + } + }, + selectAllImages: (state, action: PayloadAction) => { + state.selectedImageIds = action.payload; + }, + deselectAllImages: (state) => { + state.selectedImageIds = []; + }, + setSelectionMode: (state, action: PayloadAction) => { + state.isSelectionMode = action.payload; + if (!action.payload) { + state.selectedImageIds = []; + } + }, + removeFromSelection: (state, action: PayloadAction) => { + state.selectedImageIds = state.selectedImageIds.filter( + (id) => !action.payload.includes(id) + ); + }, + }, +}); + +export const { + toggleImageSelection, + selectAllImages, + deselectAllImages, + setSelectionMode, + removeFromSelection, +} = selectionSlice.actions; + +export default selectionSlice.reducer; diff --git a/frontend/src/utils/config.ts b/frontend/src/utils/config.ts new file mode 100644 index 000000000..122975d06 --- /dev/null +++ b/frontend/src/utils/config.ts @@ -0,0 +1,3 @@ +export const getBackendUrl = (): string => { + return process.env.VITE_BACKEND_URL || 'http://localhost:8000'; +};