diff --git a/backend/app/database/images.py b/backend/app/database/images.py index ec9541a56..ed69772d3 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -1,6 +1,6 @@ # Standard library imports import sqlite3 -from typing import Any, List, Mapping, Tuple, TypedDict, Union +from typing import Any, List, Mapping, Optional, Tuple, TypedDict, Union # App-specific imports from app.config.settings import ( @@ -82,6 +82,16 @@ def db_create_images_table() -> None: """ ) + # Create deleted_images table (tombstone) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS deleted_images ( + path TEXT PRIMARY KEY, + deleted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.commit() conn.close() @@ -419,3 +429,75 @@ def db_toggle_image_favourite_status(image_id: str) -> bool: return False finally: conn.close() + + +def db_get_image_by_id(image_id: str) -> Optional[dict]: + """ + Fetch a single image record by its ID. + """ + conn = _connect() + cursor = conn.cursor() + try: + cursor.execute( + "SELECT id, path, folder_id, thumbnailPath, metadata, isTagged, isFavourite FROM images WHERE id = ?", + (image_id,), + ) + row = cursor.fetchone() + if not row: + return None + + from app.utils.images import image_util_parse_metadata + + ( + img_id, + path, + folder_id, + thumbnail_path, + metadata, + is_tagged, + is_favourite, + ) = row + return { + "id": img_id, + "path": path, + "folder_id": str(folder_id), + "thumbnailPath": thumbnail_path, + "metadata": image_util_parse_metadata(metadata), + "isTagged": bool(is_tagged), + "isFavourite": bool(is_favourite), + } + finally: + conn.close() + + +def db_add_to_deleted_images(path: str) -> bool: + """ + Record a path in the deleted_images tombstone table. + """ + conn = _connect() + cursor = conn.cursor() + try: + cursor.execute( + "INSERT OR IGNORE INTO deleted_images (path) VALUES (?)", + (path,), + ) + conn.commit() + return True + except Exception as e: + logger.error(f"Error adding to deleted_images: {e}") + return False + finally: + conn.close() + + +def db_is_image_deleted(path: str) -> bool: + """ + Check if a path exists in the deleted_images tombstone table. + """ + conn = _connect() + cursor = conn.cursor() + try: + cursor.execute("SELECT 1 FROM deleted_images WHERE path = ?", (path,)) + return cursor.fetchone() is not None + finally: + conn.close() diff --git a/backend/app/logging/setup_logging.py b/backend/app/logging/setup_logging.py index 0eedecaa9..eed80cff0 100644 --- a/backend/app/logging/setup_logging.py +++ b/backend/app/logging/setup_logging.py @@ -243,16 +243,9 @@ def emit(self, record: logging.LogRecord) -> None: # Create a message that includes the original module in the format msg = record.getMessage() - record.msg = f"[{module_name}] {msg}" - record.args = () - # Clear exception / stack info to avoid duplicate traces - record.exc_info = None - record.stack_info = None - - root_logger = logging.getLogger() - for handler in root_logger.handlers: - if handler is not self: - handler.handle(record) + # Use the root logger to avoid recursion if the module-specific logger + # is the one we are currently intercepting. + logging.getLogger().log(record.levelno, f"[{module_name}] {msg}") def configure_uvicorn_logging(component_name: str) -> None: diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index 2e40cd825..f7fb42995 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -1,10 +1,15 @@ from fastapi import APIRouter, HTTPException, Query, status from typing import List, Optional -from app.database.images import db_get_all_images -from app.schemas.images import ErrorResponse -from app.utils.images import image_util_parse_metadata +from app.database.images import ( + db_get_all_images, + db_toggle_image_favourite_status, + db_get_image_by_id, + db_delete_images_by_ids, + db_add_to_deleted_images, +) +from app.schemas.images import ErrorResponse, DeleteImageResponse +from app.utils.images import image_util_parse_metadata, image_util_delete_image_files from pydantic import BaseModel -from app.database.images import db_toggle_image_favourite_status from app.logging.setup_logging import get_logger # Initialize logger @@ -128,3 +133,62 @@ class ImageInfoResponse(BaseModel): isTagged: bool isFavourite: bool tags: Optional[List[str]] = None + + +@router.delete( + "/{image_id}", + response_model=DeleteImageResponse, + responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, +) +def delete_image(image_id: str): + """ + Explicitly delete an image from the library. + Steps: + 1. Fetch image record from DB. + 2. Add to 'deleted_images' tombstone (prevents re-sync). + 3. Delete physical files (original + thumbnail). + 4. Delete DB record (triggers cascaded delete for tags/metadata). + """ + try: + logger.info(f"Image deletion requested for ID: {image_id}") + + # 1. Fetch image record + image = db_get_image_by_id(image_id) + if not image: + logger.warning(f"Image deletion failed: ID {image_id} not found") + raise HTTPException(status_code=404, detail="Image not found") + + original_path = image.get("path") + + # 2. Add to tombstone + db_add_to_deleted_images(original_path) + + # 3. Delete files + # We do this before DB deletion to have the paths available, + # but if this fails, we still proceed with DB cleanup to keep it consistent. + image_util_delete_image_files(image) + + # 4. Delete DB record + # This will trigger ON DELETE CASCADE for faces, album_images, image_classes + db_delete_images_by_ids([image_id]) + + logger.info(f"Successfully deleted image {image_id} and path {original_path}") + + return DeleteImageResponse( + success=True, + message="Image deleted successfully", + data=image_id, + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting image {image_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, + error="Internal server error", + message=f"Failed to delete image: {str(e)}", + ).model_dump(), + ) diff --git a/backend/app/utils/images.py b/backend/app/utils/images.py index c3b202205..97d99188e 100644 --- a/backend/app/utils/images.py +++ b/backend/app/utils/images.py @@ -15,6 +15,7 @@ db_insert_image_classes_batch, db_get_images_by_folder_ids, db_delete_images_by_ids, + db_is_image_deleted, ) from app.models.FaceDetector import FaceDetector from app.models.ObjectClassifier import ObjectClassifier @@ -151,6 +152,11 @@ def image_util_prepare_image_records( """ image_records = [] for image_path in image_files: + # Check if image was explicitly deleted before (tombstone) + if db_is_image_deleted(image_path): + logger.debug(f"Skipping tombstoned image: {image_path}") + continue + folder_id = image_util_find_folder_id_for_image(image_path, folder_path_to_id) if not folder_id: @@ -511,3 +517,36 @@ def image_util_parse_metadata(db_metadata: Any) -> Mapping[str, Any]: return db_metadata return {} + + +def image_util_delete_image_files(image: dict) -> bool: + """ + Safely delete original image and its thumbnail from disk. + + Args: + image: Image record dictionary containing 'path' and 'thumbnailPath' + """ + try: + original_path = image.get("path") + thumbnail_path = image.get("thumbnailPath") + + # Delete original file + if original_path and os.path.exists(original_path): + try: + os.remove(original_path) + logger.info(f"Deleted original image: {original_path}") + except OSError as e: + logger.error(f"Error deleting original image {original_path}: {e}") + + # Delete thumbnail + if thumbnail_path and os.path.exists(thumbnail_path): + try: + os.remove(thumbnail_path) + logger.info(f"Deleted thumbnail: {thumbnail_path}") + except OSError as e: + logger.error(f"Error deleting thumbnail {thumbnail_path}: {e}") + + return True + except Exception as e: + logger.error(f"Unexpected error in image_util_delete_image_files: {e}") + return False diff --git a/backend/tests/test_image_deletion.py b/backend/tests/test_image_deletion.py new file mode 100644 index 000000000..1c8cee5ad --- /dev/null +++ b/backend/tests/test_image_deletion.py @@ -0,0 +1,121 @@ +import os +import pytest +from fastapi.testclient import TestClient +import sys +from pathlib import Path + +# Add backend to path to allow imports and find main.py +sys.path.append(str(Path(__file__).parent.parent)) + +from main import app +from app.database.images import db_get_image_by_id, db_is_image_deleted, db_bulk_insert_images, db_create_images_table +from app.config.settings import DATABASE_PATH +import sqlite3 +import uuid +import json + +client = TestClient(app) + +@pytest.fixture(autouse=True) +def setup_db(): + # Make sure images table (and deleted_images) is created + db_create_images_table() + + # Use config-defined DATABASE_PATH + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + # Ensure folders table exists (might already be created by conftest) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS folders ( + folder_id TEXT PRIMARY KEY, + folder_path TEXT UNIQUE + ) + """) + # Insert a fake folder for FK constraints + cursor.execute("INSERT OR IGNORE INTO folders (folder_id, folder_path) VALUES ('1', '/fake/path')") + conn.commit() + conn.close() + +@pytest.fixture +def mock_image(tmp_path): + # Setup: Create a fake image file and a fake thumbnail + img_dir = tmp_path / "images" + img_dir.mkdir() + img_path = img_dir / "test_image.jpg" + img_path.write_bytes(b"fake image data") + + thumb_dir = tmp_path / "thumbnails" + thumb_dir.mkdir() + thumb_path = thumb_dir / "test_thumb.jpg" + thumb_path.write_bytes(b"fake thumb data") + + image_id = str(uuid.uuid4()) + image_record = { + "id": image_id, + "path": str(img_path), + "folder_id": 1, + "thumbnailPath": str(thumb_path), + "metadata": json.dumps({ + "name": "test_image.jpg", + "width": 100, + "height": 100, + "file_size": 100, + "file_location": str(img_path), + "item_type": "image/jpeg" + }), + "isTagged": False + } + + # Insert into DB + db_bulk_insert_images([image_record]) + + return image_record + +def test_delete_image_success(mock_image): + image_id = mock_image["id"] + img_path = mock_image["path"] + thumb_path = mock_image["thumbnailPath"] + + # 1. Verify existence before deletion + assert os.path.exists(img_path) + assert os.path.exists(thumb_path) + assert db_get_image_by_id(image_id) is not None + + # 2. Call DELETE API + response = client.delete(f"/images/{image_id}") + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + # 3. Verify deletion + assert not os.path.exists(img_path) + assert not os.path.exists(thumb_path) + assert db_get_image_by_id(image_id) is None + assert db_is_image_deleted(img_path) is True + +def test_delete_image_not_found(): + response = client.delete("/images/non-existent-id") + assert response.status_code == 404 + +def test_tombstone_prevents_indexing(mock_image): + image_id = mock_image["id"] + img_path = mock_image["path"] + + # Delete the image first + client.delete(f"/images/{image_id}") + assert db_is_image_deleted(img_path) is True + + # Re-create the file manually + os.makedirs(os.path.dirname(img_path), exist_ok=True) + with open(img_path, "wb") as f: + f.write(b"recreated file") + + # Try to re-index it via utilities + from app.utils.images import image_util_prepare_image_records + + # Mocking folder_path_to_id + folder_path = str(Path(img_path).parent) + records = image_util_prepare_image_records([str(img_path)], {folder_path: 1}) + + # It should skip it because of the tombstone + assert len(records) == 0 diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index fbf40091b..faed8eb82 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -926,6 +926,69 @@ } } }, + "/images/{image_id}": { + "delete": { + "tags": [ + "Images" + ], + "summary": "Delete Image", + "description": "Explicitly delete an image from the library.\nSteps:\n1. Fetch image record from DB.\n2. Add to 'deleted_images' tombstone (prevents re-sync).\n3. Delete physical files (original + thumbnail).\n4. Delete DB record (triggers cascaded delete for tags/metadata).", + "operationId": "delete_image_images__image_id__delete", + "parameters": [ + { + "name": "image_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Image Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteImageResponse" + } + } + } + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__images__ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__images__ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/face-clusters/{cluster_id}": { "put": { "tags": [ @@ -1619,6 +1682,29 @@ ], "title": "DeleteFoldersResponse" }, + "DeleteImageResponse": { + "properties": { + "data": { + "type": "string", + "title": "Data" + }, + "message": { + "type": "string", + "title": "Message" + }, + "success": { + "type": "boolean", + "title": "Success" + } + }, + "type": "object", + "required": [ + "data", + "message", + "success" + ], + "title": "DeleteImageResponse" + }, "FaceSearchRequest": { "properties": { "path": { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ab218ecaf..09e07c890 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-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", @@ -3479,6 +3480,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "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-dialog": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0a53f1b8d..b30da8035 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-context-menu": "^2.2.16", "@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-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index b0e9217df..b85100bc4 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -17,6 +17,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -56,12 +74,58 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "arrayref" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "ashpd" version = "0.11.0" @@ -249,6 +313,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.17", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec", +] + [[package]] name = "base64" version = "0.21.7" @@ -282,6 +389,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -343,6 +459,12 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + [[package]] name = "bumpalo" version = "3.19.0" @@ -361,6 +483,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.10.1" @@ -444,6 +572,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "739eb0f94557554b3ca9a86d2d37bebd49c5e6d0c1d2bda35ba5bdac830befc2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -500,6 +630,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -581,6 +720,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -977,6 +1125,26 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1004,6 +1172,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -1027,9 +1201,9 @@ dependencies = [ [[package]] name = "exr" -version = "1.73.0" +version = "1.74.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" dependencies = [ "bit_field", "half", @@ -1046,6 +1220,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -1348,6 +1542,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1388,9 +1592,9 @@ dependencies = [ [[package]] name = "gif" -version = "0.13.3" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" dependencies = [ "color_quant", "weezl", @@ -1742,7 +1946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -1855,22 +2059,44 @@ dependencies = [ [[package]] name = "image" -version = "0.24.9" +version = "0.25.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", - "byteorder", + "byteorder-lite", "color_quant", "exr", "gif", - "jpeg-decoder", + "image-webp", + "moxcms", "num-traits", - "png", + "png 0.18.0", "qoi", + "ravif", + "rayon", + "rgb", "tiff", + "zune-core 0.5.0", + "zune-jpeg 0.5.8", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + [[package]] name = "indexmap" version = "1.9.3" @@ -1903,6 +2129,17 @@ dependencies = [ "cfb", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1938,6 +2175,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1990,12 +2236,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] -name = "jpeg-decoder" -version = "0.3.2" +name = "jobserver" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "rayon", + "getrandom 0.3.4", + "libc", ] [[package]] @@ -2095,6 +2342,16 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2153,6 +2410,15 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2196,6 +2462,16 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.7.6" @@ -2244,6 +2520,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.1" @@ -2259,7 +2545,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation 0.3.2", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.17", "windows-sys 0.60.2", @@ -2320,12 +2606,68 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2745,6 +3087,18 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2896,6 +3250,7 @@ name = "picto_py" version = "1.1.0" dependencies = [ "anyhow", + "arboard", "arrayref", "base64 0.21.7", "chrono", @@ -2976,6 +3331,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -3088,6 +3456,34 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.108", +] + +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + [[package]] name = "qoi" version = "0.4.1" @@ -3097,6 +3493,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.37.5" @@ -3295,6 +3697,56 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.17", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3467,6 +3919,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" + [[package]] name = "ring" version = "0.16.20" @@ -3905,6 +4363,15 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -4261,7 +4728,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -4615,13 +5082,16 @@ dependencies = [ [[package]] name = "tiff" -version = "0.9.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" dependencies = [ + "fax", "flate2", - "jpeg-decoder", + "half", + "quick-error", "weezl", + "zune-jpeg 0.4.21", ] [[package]] @@ -4919,7 +5389,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation 0.3.2", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.17", "windows-sys 0.60.2", @@ -5067,6 +5537,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "version-compare" version = "0.2.0" @@ -5925,6 +6406,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -5935,6 +6433,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yoke" version = "0.8.1" @@ -6112,6 +6616,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773" + [[package]] name = "zune-inflate" version = "0.2.54" @@ -6121,6 +6637,24 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5" +dependencies = [ + "zune-core 0.5.0", +] + [[package]] name = "zvariant" version = "5.8.0" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 27a402134..b7836a0e6 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -16,7 +16,8 @@ walkdir = "2.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1" anyhow = "1.0" -image = "0.24.6" +arboard = "3.4" +image = "0.25" ring = "0.16.20" data-encoding = "2.3.2" tokio = { version = "1", features = ["macros"] } diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 316c1c03e..63b81de45 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -6,8 +6,67 @@ mod services; use tauri::path::BaseDirectory; use tauri::Manager; +// -------- Clipboard imports -------- +// -------- Clipboard imports -------- +use tauri::command; +use std::path::{Path, PathBuf}; +use arboard::{Clipboard, ImageData}; +use std::borrow::Cow; +use image::GenericImageView; + +// -------- Clipboard command -------- +#[command] +fn copy_image_to_clipboard(path: String) -> Result<(), String> { + let path = PathBuf::from(&path); + + // 🔐 Security checks + if !path.is_absolute() { + return Err("Expected absolute file path".into()); + } + + if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + return Err("Invalid path traversal detected".into()); + } + + if !path.exists() { + return Err("File does not exist".into()); + } + + if !path.is_file() { + return Err("Path is not a file".into()); + } + + // Load image + let img = image::open(&path) + .map_err(|e| format!("Failed to open image: {}", e))?; + + // Optional: prevent huge images + let (w, h) = img.dimensions(); + if w > 8000 || h > 8000 { + return Err("Image too large to copy".into()); + } + + let rgba = img.to_rgba8(); + + let image_data = ImageData { + width: rgba.width() as usize, + height: rgba.height() as usize, + bytes: Cow::Owned(rgba.into_raw()), + }; + + let mut clipboard = Clipboard::new() + .map_err(|e| format!("Clipboard init failed: {}", e))?; + + clipboard + .set_image(image_data) + .map_err(|e| format!("Clipboard write failed: {}", e))?; + + Ok(()) +} + fn main() { tauri::Builder::default() + // -------- Existing plugins (unchanged) -------- .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build()) @@ -15,6 +74,8 @@ fn main() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) + + // -------- Existing setup (unchanged) -------- .setup(|app| { let resource_path = app .path() @@ -22,9 +83,13 @@ fn main() { println!("Resource path: {:?}", resource_path); Ok(()) }) + + // -------- Register commands -------- .invoke_handler(tauri::generate_handler![ services::get_resources_folder_path, + copy_image_to_clipboard ]) + .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 8ad815dfd..938076939 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -7,7 +7,11 @@ }, "bundle": { "active": true, - "targets": ["nsis", "deb", "app"], + "targets": [ + "nsis", + "deb", + "app" + ], "createUpdaterArtifacts": true, "linux": { "deb": { @@ -41,6 +45,9 @@ "endpoints": [ "https://github.com/AOSSIE-Org/PictoPy/releases/latest/download/latest.json" ] + }, + "clipboard": { + "enable": true } }, "app": { @@ -59,10 +66,12 @@ ], "security": { "assetProtocol": { - "scope": ["**"], + "scope": [ + "**" + ], "enable": true }, "csp": "default-src 'self'; img-src 'self' data: asset: http://asset.localhost; media-src 'self' blob: data:; connect-src 'self' ipc: http://ipc.localhost http://localhost:52123 ws://localhost:52123 http://localhost:52124 ws://localhost:52124" } } -} +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2c5b6bddb..ceb661697 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { GlobalLoader } from './components/Loader/GlobalLoader'; import { InfoDialog } from './components/Dialog/InfoDialog'; import { useSelector } from 'react-redux'; import { RootState } from './app/store'; + const App: React.FC = () => { const { loading, message } = useSelector((state: RootState) => state.loader); const { diff --git a/frontend/src/components/Dialog/InfoDialog.tsx b/frontend/src/components/Dialog/InfoDialog.tsx index 59914e19d..767c49013 100644 --- a/frontend/src/components/Dialog/InfoDialog.tsx +++ b/frontend/src/components/Dialog/InfoDialog.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { Info, AlertTriangle } from 'lucide-react'; +import { Info, AlertTriangle, CheckCircle } from 'lucide-react'; import { Dialog, DialogContent, @@ -40,6 +39,12 @@ export const InfoDialog: React.FC = ({ icon: , buttonVariant: 'destructive' as const, }, + success: { + iconColor: 'text-green-500', + messageColor: '', + icon: , + buttonVariant: 'default' as const, + }, }; const { icon, iconColor, messageColor, buttonVariant } = diff --git a/frontend/src/components/Media/ChronologicalGallery.tsx b/frontend/src/components/Media/ChronologicalGallery.tsx index f033e35a0..766195f2d 100644 --- a/frontend/src/components/Media/ChronologicalGallery.tsx +++ b/frontend/src/components/Media/ChronologicalGallery.tsx @@ -20,6 +20,7 @@ type ChronologicalGalleryProps = { className?: string; onMonthOffsetsChange?: (markers: MonthMarker[]) => void; scrollContainerRef?: React.RefObject; + onViewInfo?: (image: Image, index: number) => void; }; export const ChronologicalGallery = ({ @@ -29,6 +30,7 @@ export const ChronologicalGallery = ({ className = '', onMonthOffsetsChange, scrollContainerRef, + onViewInfo, }: ChronologicalGalleryProps) => { const dispatch = useDispatch(); const monthHeaderRefs = useRef>(new Map()); @@ -166,6 +168,8 @@ export const ChronologicalGallery = ({
dispatch(setCurrentViewIndex(chronologicalIndex)) } diff --git a/frontend/src/components/Media/ImageCard.tsx b/frontend/src/components/Media/ImageCard.tsx index 0cc6a715a..5716b232e 100644 --- a/frontend/src/components/Media/ImageCard.tsx +++ b/frontend/src/components/Media/ImageCard.tsx @@ -1,12 +1,32 @@ + import { AspectRatio } from '@/components/ui/aspect-ratio'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; -import { Check, Heart } from 'lucide-react'; +import { + Check, + Heart, + Info, + Copy, + +} from 'lucide-react'; import { useCallback, useState } from 'react'; import { Image } from '@/types/Media'; import { ImageTags } from './ImageTags'; import { convertFileSrc } from '@tauri-apps/api/core'; import { useToggleFav } from '@/hooks/useToggleFav'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; + +import { useDispatch } from 'react-redux'; + +import { invoke } from '@tauri-apps/api/core'; + +import { showInfoDialog } from '@/features/infoDialogSlice'; interface ImageCardViewProps { image: Image; @@ -15,6 +35,7 @@ interface ImageCardViewProps { showTags?: boolean; onClick?: () => void; imageIndex?: number; + onViewInfo?: (image: Image, index: number) => void; } export function ImageCard({ @@ -23,8 +44,11 @@ export function ImageCard({ isSelected = false, showTags = true, onClick, + imageIndex = 0, + onViewInfo, }: ImageCardViewProps) { const [isImageHovered, setIsImageHovered] = useState(false); + const dispatch = useDispatch(); // Default to empty array if no tags are provided const tags = image.tags || []; const { toggleFavourite } = useToggleFav(); @@ -34,72 +58,147 @@ export function ImageCard({ toggleFavourite(image.id); } }, [image, toggleFavourite]); - return ( -
setIsImageHovered(true)} - onMouseLeave={() => setIsImageHovered(false)} - onClick={onClick} - > -
- {/* Selection tick mark */} - {isSelected && ( -
- -
- )} - - {'Sample { + try { + await invoke('copy_image_to_clipboard', { + path: image.path, + }); + + dispatch( + showInfoDialog({ + title: 'Success', + message: 'Image copied to clipboard', + variant: 'success', + }), + ); + } catch (err) { + console.error(err); + dispatch( + showInfoDialog({ + title: 'Error', + message: 'Failed to copy image to clipboard', + variant: 'error', + }), + ); + } + }; + + + + + + const handleViewInfo = () => { + if (onViewInfo) { + onViewInfo(image, imageIndex); + } else if (onClick) { + // Fallback to old behavior if no handler provided + onClick(); + } + }; + + return ( + + +
setIsImageHovered(true)} + onMouseLeave={() => setIsImageHovered(false)} + onClick={onClick} + > +
+ {/* Selection tick mark */} + {isSelected && ( +
+ +
)} - /> - {/* Dark overlay on hover */} -
- - {/* Image actions on hover */} -
- + + + {'Sample + {/* Dark overlay on hover */} +
+ + {/* Image actions on hover */} +
+ +
+ + + {/* Tag section */} +
-
- - {/* Tag section */} - -
-
+
+ + + + + { + e.stopPropagation(); + handleToggleFavourite(); + }}> + + {image.isFavourite ? "Unfavourite" : "Favourite"} + + + + + + { + e.stopPropagation(); + handleCopy(); + }}> + + Copy Image + + + { + e.stopPropagation(); + handleViewInfo(); + }}> + + View Info + + + + ); } diff --git a/frontend/src/components/Media/MediaInfoPanel.tsx b/frontend/src/components/Media/MediaInfoPanel.tsx index c192aef4b..46ba85d9e 100644 --- a/frontend/src/components/Media/MediaInfoPanel.tsx +++ b/frontend/src/components/Media/MediaInfoPanel.tsx @@ -10,6 +10,7 @@ import { SquareArrowOutUpRight, } from 'lucide-react'; import { Image } from '@/types/Media'; +import { cn } from '@/lib/utils'; interface MediaInfoPanelProps { show: boolean; @@ -17,6 +18,7 @@ interface MediaInfoPanelProps { currentImage: Image | null; currentIndex: number; totalImages: number; + className?: string; } export const MediaInfoPanel: React.FC = ({ @@ -25,6 +27,7 @@ export const MediaInfoPanel: React.FC = ({ currentImage, currentIndex, totalImages, + className, }) => { const getFormattedDate = () => { if (currentImage?.metadata?.date_created) { @@ -61,7 +64,7 @@ export const MediaInfoPanel: React.FC = ({ if (!show) return null; return ( -
+

Image Details