diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index ba577d070..310f834dc 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -6,8 +6,92 @@ mod services; use tauri::path::BaseDirectory; use tauri::Manager; +// -------- Clipboard imports -------- +use tauri::command; +use tauri::AppHandle; +use std::path::PathBuf; +use arboard::{Clipboard, ImageData}; +use std::borrow::Cow; +use image::GenericImageView; + +// -------- Clipboard command -------- +/// Copy an image file into the system clipboard. +/// +/// Resolves `path` relative to the application's AppData directory, loads the image, +/// converts it to RGBA, and places it on the system clipboard. +/// +/// # Parameters +/// +/// - `app`: Tauri application handle used to resolve the given path relative to the AppData directory. +/// - `path`: File path to the image, interpreted relative to the AppData base directory. +/// +/// # Returns +/// +/// `Ok(())` on success; `Err(String)` on failure. Possible error messages include: +/// - `"Invalid file path"` if the provided path cannot be resolved. +/// - `"File does not exist"` if the resolved path is missing. +/// - `"Image too large to copy to clipboard"` if the image exceeds the configured pixel limit. +/// - Other errors returned as strings (e.g., image decoding or clipboard errors). +/// +/// # Examples +/// +/// ```ignore +/// // `app_handle` is a `tauri::AppHandle` obtained in a Tauri command or setup. +/// let _ = copy_image_to_clipboard(app_handle, "resources/images/logo.png".into())?; +/// ``` +#[command] +fn copy_image_to_clipboard(app: AppHandle, path: String) -> Result<(), String> { + let resolved: PathBuf = app + .path() + .resolve(&path, BaseDirectory::AppData) + .map_err(|_| "Invalid file path")?; + + if !resolved.exists() { + return Err("File does not exist".into()); + } + + let img = image::open(&resolved).map_err(|e| e.to_string())?; + + // Fix: Add a Size Limit + let (width, height) = img.dimensions(); + const MAX_PIXELS: u32 = 30_000_000; // ~120MB RGBA + + if width * height > MAX_PIXELS { + return Err("Image too large to copy to clipboard".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| e.to_string())?; + clipboard + .set_image(image_data) + .map_err(|e| e.to_string())?; + + Ok(()) +} + +/// Entry point that builds and runs the Tauri application with its plugins, setup hook, and command handlers. +/// +/// The application is configured with the project's plugin set, a setup hook that resolves and prints a resource +/// path, and registers invocation commands (including `services::get_server_path` and `copy_image_to_clipboard`). +/// +/// # Examples +/// +/// ```no_run +/// // Starts the application (no-op in documentation tests) +/// fn main() { +/// crate::main() +/// } +/// ``` 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 +99,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,7 +108,13 @@ fn main() { println!("Resource path: {:?}", resource_path); Ok(()) }) - .invoke_handler(tauri::generate_handler![services::get_server_path,]) + + // -------- Register commands -------- + .invoke_handler(tauri::generate_handler![ + services::get_server_path, + copy_image_to_clipboard + ]) + .run(tauri::generate_context!()) .expect("error while running tauri application"); -} +} \ No newline at end of file diff --git a/frontend/src/components/Media/ImageCard.tsx b/frontend/src/components/Media/ImageCard.tsx index 0cc6a715a..fc549a109 100644 --- a/frontend/src/components/Media/ImageCard.tsx +++ b/frontend/src/components/Media/ImageCard.tsx @@ -1,12 +1,30 @@ 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,16 +33,36 @@ interface ImageCardViewProps { showTags?: boolean; onClick?: () => void; imageIndex?: number; + onViewInfo?: (image: Image, index: number) => void; } +/** + * Renders an interactive image card with hover actions, selection state, tags, and a context menu. + * + * Renders the provided image inside an aspect-ratio container and shows hover controls for favouriting. + * The card displays a selection ring when selected, overlays image tags, and exposes a right-click context + * menu with actions to favourite/unfavourite, copy the image to clipboard, and view image info. + * + * @param image - Image object to render (thumbnailPath, path, id, tags, isFavourite, etc.) + * @param className - Optional additional CSS class names applied to the card container + * @param isSelected - Whether the card is visually marked as selected + * @param showTags - Whether to display the image's tags overlay + * @param onClick - Optional click handler invoked when the card is clicked + * @param imageIndex - Optional index of the image used when calling onViewInfo + * @param onViewInfo - Optional handler invoked with (image, imageIndex) when "View Info" is selected; falls back to `onClick` if not provided + * @returns A JSX element representing the interactive image card + */ export function ImageCard({ image, className, 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 +72,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 + + + + ); -} +} \ No newline at end of file