diff --git a/backend/app/database/face_clusters.py b/backend/app/database/face_clusters.py index c3c74a02b..5bfa956e2 100644 --- a/backend/app/database/face_clusters.py +++ b/backend/app/database/face_clusters.py @@ -275,6 +275,8 @@ def db_get_images_by_cluster_id( rows = cursor.fetchall() conn.close() + from app.utils.images import image_util_parse_metadata + images = [] for row in rows: ( @@ -299,7 +301,7 @@ def db_get_images_by_cluster_id( "image_id": image_id, "image_path": image_path, "thumbnail_path": thumbnail_path, - "metadata": metadata, + "metadata": image_util_parse_metadata(metadata), "face_id": face_id, "confidence": confidence, "bbox": bbox, diff --git a/backend/app/database/faces.py b/backend/app/database/faces.py index b44438315..9a8a0269a 100644 --- a/backend/app/database/faces.py +++ b/backend/app/database/faces.py @@ -159,6 +159,8 @@ def get_all_face_embeddings(): ) results = cursor.fetchall() + from app.utils.images import image_util_parse_metadata + images_dict = {} for ( embeddings, @@ -184,7 +186,7 @@ def get_all_face_embeddings(): "path": path, "folder_id": folder_id, "thumbnailPath": thumbnail_path, - "metadata": metadata, + "metadata": image_util_parse_metadata(metadata), "isTagged": bool(is_tagged), "tags": [], } diff --git a/backend/app/database/images.py b/backend/app/database/images.py index 3b9e2647d..361818947 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 List, Tuple, TypedDict +from typing import Any, List, Mapping, Tuple, TypedDict, Union # App-specific imports from app.config.settings import ( @@ -21,15 +21,32 @@ class ImageRecord(TypedDict): path: ImagePath folder_id: FolderId thumbnailPath: str - metadata: str + metadata: Union[Mapping[str, Any], str] isTagged: bool +class UntaggedImageRecord(TypedDict): + """Represents an image record returned for tagging.""" + + id: ImageId + path: ImagePath + folder_id: FolderId + thumbnailPath: str + metadata: Mapping[str, Any] + + ImageClassPair = Tuple[ImageId, ClassId] -def db_create_images_table() -> None: +def _connect() -> sqlite3.Connection: conn = sqlite3.connect(DATABASE_PATH) + # Ensure ON DELETE CASCADE and other FKs are enforced + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def db_create_images_table() -> None: + conn = _connect() cursor = conn.cursor() # Create new images table with merged fields @@ -69,15 +86,23 @@ def db_bulk_insert_images(image_records: List[ImageRecord]) -> bool: if not image_records: return True - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: cursor.executemany( """ - INSERT OR IGNORE INTO images (id, path, folder_id, thumbnailPath, metadata, isTagged) + INSERT INTO images (id, path, folder_id, thumbnailPath, metadata, isTagged) VALUES (:id, :path, :folder_id, :thumbnailPath, :metadata, :isTagged) - """, + ON CONFLICT(path) DO UPDATE SET + folder_id=excluded.folder_id, + thumbnailPath=excluded.thumbnailPath, + metadata=excluded.metadata, + isTagged=CASE + WHEN excluded.isTagged THEN 1 + ELSE images.isTagged + END + """, image_records, ) conn.commit() @@ -97,7 +122,7 @@ def db_get_all_images() -> List[dict]: Returns: List of dictionaries containing all image data including tags """ - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: @@ -132,18 +157,23 @@ def db_get_all_images() -> List[dict]: tag_name, ) in results: if image_id not in images_dict: + # Safely parse metadata JSON -> dict + from app.utils.images import image_util_parse_metadata + + metadata_dict = image_util_parse_metadata(metadata) + images_dict[image_id] = { "id": image_id, "path": path, - "folder_id": folder_id, + "folder_id": str(folder_id), "thumbnailPath": thumbnail_path, - "metadata": metadata, + "metadata": metadata_dict, "isTagged": bool(is_tagged), "tags": [], } - # Add tag if it exists - if tag_name: + # Add tag if it exists (avoid duplicates) + if tag_name and tag_name not in images_dict[image_id]["tags"]: images_dict[image_id]["tags"].append(tag_name) # Convert to list and set tags to None if empty @@ -165,7 +195,7 @@ def db_get_all_images() -> List[dict]: conn.close() -def db_get_untagged_images() -> List[ImageRecord]: +def db_get_untagged_images() -> List[UntaggedImageRecord]: """ Find all images that need AI tagging. Returns images where: @@ -175,7 +205,7 @@ def db_get_untagged_images() -> List[ImageRecord]: Returns: List of dictionaries containing image data: id, path, folder_id, thumbnailPath, metadata """ - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: @@ -193,13 +223,16 @@ def db_get_untagged_images() -> List[ImageRecord]: untagged_images = [] for image_id, path, folder_id, thumbnail_path, metadata in results: + from app.utils.images import image_util_parse_metadata + + md = image_util_parse_metadata(metadata) untagged_images.append( { "id": image_id, "path": path, - "folder_id": folder_id, + "folder_id": str(folder_id) if folder_id is not None else None, "thumbnailPath": thumbnail_path, - "metadata": metadata, + "metadata": md, } ) @@ -220,7 +253,7 @@ def db_update_image_tagged_status(image_id: ImageId, is_tagged: bool = True) -> Returns: True if update was successful, False otherwise """ - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: @@ -251,7 +284,7 @@ def db_insert_image_classes_batch(image_class_pairs: List[ImageClassPair]) -> bo if not image_class_pairs: return True - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: @@ -287,7 +320,7 @@ def db_get_images_by_folder_ids( if not folder_ids: return [] - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: @@ -323,7 +356,7 @@ def db_delete_images_by_ids(image_ids: List[ImageId]) -> bool: if not image_ids: return True - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: diff --git a/backend/app/routes/face_clusters.py b/backend/app/routes/face_clusters.py index 351a7a2f0..9987b49d8 100644 --- a/backend/app/routes/face_clusters.py +++ b/backend/app/routes/face_clusters.py @@ -1,7 +1,7 @@ import logging import uuid import os -from typing import Optional, List +from typing import Optional, List, Dict, Any from pydantic import BaseModel from app.config.settings import CONFIDENCE_PERCENT, DEFAULT_FACENET_MODEL from fastapi import APIRouter, HTTPException, status @@ -42,7 +42,7 @@ class ImageData(BaseModel): path: str folder_id: str thumbnailPath: str - metadata: str + metadata: Dict[str, Any] isTagged: bool tags: Optional[List[str]] = None bboxes: BoundingBox diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index ad387154d..5f02aafcb 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -2,18 +2,32 @@ 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 pydantic import BaseModel router = APIRouter() # Response Models +class MetadataModel(BaseModel): + name: str + date_created: Optional[str] + width: int + height: int + file_location: str + file_size: int + item_type: str + latitude: Optional[float] = None + longitude: Optional[float] = None + location: Optional[str] = None + + class ImageData(BaseModel): id: str path: str folder_id: str thumbnailPath: str - metadata: str + metadata: MetadataModel isTagged: bool tags: Optional[List[str]] = None @@ -42,7 +56,7 @@ def get_all_images(): path=image["path"], folder_id=image["folder_id"], thumbnailPath=image["thumbnailPath"], - metadata=image["metadata"], + metadata=image_util_parse_metadata(image["metadata"]), isTagged=image["isTagged"], tags=image["tags"], ) diff --git a/backend/app/schemas/face_clusters.py b/backend/app/schemas/face_clusters.py index 11803cce6..64e44593d 100644 --- a/backend/app/schemas/face_clusters.py +++ b/backend/app/schemas/face_clusters.py @@ -1,5 +1,5 @@ from pydantic import BaseModel -from typing import List, Optional, Dict, Union +from typing import List, Optional, Dict, Union, Any # Request Models @@ -50,7 +50,7 @@ class ImageInCluster(BaseModel): id: str path: str thumbnailPath: Optional[str] = None - metadata: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None face_id: int confidence: Optional[float] = None bbox: Optional[Dict[str, Union[int, float]]] = None diff --git a/backend/app/utils/images.py b/backend/app/utils/images.py index ec2cec79e..fab19ef5b 100644 --- a/backend/app/utils/images.py +++ b/backend/app/utils/images.py @@ -1,7 +1,10 @@ import os import uuid -from typing import List, Tuple, Dict -from PIL import Image +import datetime +import json +import logging +from typing import List, Tuple, Dict, Any, Mapping +from PIL import Image, ExifTags from pathlib import Path from app.config.settings import THUMBNAIL_IMAGES_PATH @@ -17,6 +20,12 @@ from app.models.ObjectClassifier import ObjectClassifier +# GPS EXIF tag constant +GPS_INFO_TAG = 34853 + +logger = logging.getLogger(__name__) + + def image_util_process_folder_images(folder_data: List[Tuple[str, int, bool]]) -> bool: """Main function to process images in multiple folders based on provided folder data. @@ -55,7 +64,7 @@ def image_util_process_folder_images(folder_data: List[Tuple[str, int, bool]]) - all_image_records.extend(folder_image_records) except Exception as e: - print(f"Error processing folder {folder_path}: {e}") + logger.error(f"Error processing folder {folder_path}: {e}") continue # Continue with other folders even if one fails # Step 4: Remove obsolete images that no longer exist in filesystem @@ -68,7 +77,7 @@ def image_util_process_folder_images(folder_data: List[Tuple[str, int, bool]]) - return True # No images to process is not an error except Exception as e: - print(f"Error processing folders: {e}") + logger.error(f"Error processing folders: {e}") return False @@ -85,7 +94,7 @@ def image_util_process_untagged_images() -> bool: return True except Exception as e: - print(f"Error processing untagged images: {e}") + logger.error(f"Error processing untagged images: {e}") return False @@ -107,7 +116,7 @@ def image_util_classify_and_face_detect_images( if len(classes) > 0: # Create image-class pairs image_class_pairs = [(image_id, class_id) for class_id in classes] - print(image_class_pairs) + logger.debug(f"Image class pairs: {image_class_pairs}") # Insert the pairs into the database db_insert_image_classes_batch(image_class_pairs) @@ -152,13 +161,15 @@ def image_util_prepare_image_records( # Generate thumbnail if image_util_generate_thumbnail(image_path, thumbnail_path): + metadata = image_util_extract_metadata(image_path) + logger.debug(f"Extracted metadata for {image_path}: {metadata}") image_records.append( { "id": image_id, "path": image_path, "folder_id": folder_id, "thumbnailPath": thumbnail_path, - "metadata": "{}", # Empty JSON object as default + "metadata": json.dumps(metadata), "isTagged": False, } ) @@ -195,7 +206,7 @@ def image_util_get_images_from_folder( if os.path.isfile(file_path) and image_util_is_valid_image(file_path): image_files.append(file_path) except OSError as e: - print(f"Error reading folder {folder_path}: {e}") + logger.error(f"Error reading folder {folder_path}: {e}") return image_files @@ -215,7 +226,7 @@ def image_util_generate_thumbnail( img.save(thumbnail_path, "JPEG") # Always save thumbnails as JPEG return True except Exception as e: - print(f"Error generating thumbnail for {image_path}: {e}") + logger.error(f"Error generating thumbnail for {image_path}: {e}") return False @@ -239,13 +250,13 @@ def image_util_remove_obsolete_images(folder_id_list: List[int]) -> int: if thumbnail_path and os.path.exists(thumbnail_path): try: os.remove(thumbnail_path) - print(f"Removed obsolete thumbnail: {thumbnail_path}") + logger.info(f"Removed obsolete thumbnail: {thumbnail_path}") except OSError as e: - print(f"Error removing thumbnail {thumbnail_path}: {e}") + logger.error(f"Error removing thumbnail {thumbnail_path}: {e}") if obsolete_images: db_delete_images_by_ids(obsolete_images) - print(f"Removed {len(obsolete_images)} obsolete image(s) from database") + logger.info(f"Removed {len(obsolete_images)} obsolete image(s) from database") return len(obsolete_images) @@ -309,3 +320,191 @@ def image_util_is_valid_image(file_path: str) -> bool: return True except Exception: return False + + +def _convert_to_degrees(value): + """Converts a GPS coordinate value from DMS to decimal degrees.""" + + def to_float(v): + return ( + float(v.numerator) / float(v.denominator) + if hasattr(v, "numerator") + else float(v) + ) + + d, m, s = (to_float(v) for v in value[:3]) + return d + (m / 60.0) + (s / 3600.0) + + +def _extract_gps_coordinates(exif_data: Any) -> Tuple[float | None, float | None]: + """ + Extracts GPS coordinates from EXIF data. + Args: + exif_data: The EXIF data from an image (PIL.Image.Exif object). + Returns: + A tuple containing latitude and longitude, or (None, None) if not found. + """ + latitude = None + longitude = None + + if exif_data: + gps_data = exif_data.get_ifd(GPS_INFO_TAG) + if isinstance(gps_data, dict): + try: + lat_dms = gps_data.get(2) + lat_ref = gps_data.get(1) + lon_dms = gps_data.get(4) + lon_ref = gps_data.get(3) + + if lat_dms and lat_ref and lon_dms and lon_ref: + if isinstance(lat_ref, bytes): + lat_ref = lat_ref.decode("ascii") + if isinstance(lon_ref, bytes): + lon_ref = lon_ref.decode("ascii") + + latitude = _convert_to_degrees(lat_dms) + if lat_ref.strip() != "N": + latitude = -latitude + + longitude = _convert_to_degrees(lon_dms) + if lon_ref.strip() != "E": + longitude = -longitude + except ( + KeyError, + IndexError, + TypeError, + ValueError, + AttributeError, + ): + latitude = None + longitude = None + else: + pass + + return latitude, longitude + + +def image_util_extract_metadata(image_path: str) -> dict: + """Extract metadata for a given image file with detailed debug logging.""" + logger.debug(f"image_util_extract_metadata called for: {image_path}") + + if not os.path.exists(image_path): + return { + "name": os.path.basename(image_path), + "date_created": None, + "width": 0, + "height": 0, + "file_location": image_path, + "file_size": 0, + "item_type": "unknown", + } + + try: + stats = os.stat(image_path) + logger.debug(f"File exists. Size = {stats.st_size} bytes") + + try: + with Image.open(image_path) as img: + width, height = img.size + mime_type = Image.MIME.get(img.format, "unknown") + logger.debug(f"Pillow opened image: {width}x{height}, type={mime_type}") + + # Robust EXIF extraction with safe fallback + try: + exif_data = ( + img.getexif() + if hasattr(img, "getexif") + else getattr(img, "_getexif", lambda: None)() + ) + except Exception: + exif_data = None + + exif = dict(exif_data) if exif_data else {} + dt_original = None + latitude, longitude = _extract_gps_coordinates(exif_data) + + for k, v in exif.items(): + if ExifTags.TAGS.get(k) == "DateTimeOriginal": + dt_original = ( + v.decode("utf-8", "ignore") + if isinstance(v, (bytes, bytearray)) + else str(v) + ) + break + + # Safe parse; fall back to mtime without losing width/height + if dt_original: + try: + date_created = datetime.datetime.strptime( + dt_original.strip().split("\x00", 1)[0], + "%Y:%m:%d %H:%M:%S", + ).isoformat() + except ValueError: + date_created = datetime.datetime.fromtimestamp( + stats.st_mtime + ).isoformat() + else: + date_created = datetime.datetime.fromtimestamp( + stats.st_mtime + ).isoformat() + + metadata_dict = { + "name": os.path.basename(image_path), + "date_created": date_created, + "width": width, + "height": height, + "file_location": image_path, + "file_size": stats.st_size, + "item_type": mime_type, + } + + if latitude is not None and longitude is not None: + metadata_dict["latitude"] = latitude + metadata_dict["longitude"] = longitude + + return metadata_dict + + except Exception as e: + logger.error(f"Pillow could not open image {image_path}: {e}") + return { + "name": os.path.basename(image_path), + "date_created": datetime.datetime.fromtimestamp( + stats.st_mtime + ).isoformat(), + "file_location": image_path, + "file_size": stats.st_size, + "width": 0, + "height": 0, + "item_type": "unknown", + } + + except Exception: + return { + "name": os.path.basename(image_path), + "date_created": None, + "width": 0, + "height": 0, + "file_location": image_path, + "file_size": 0, + "item_type": "unknown", + } + + +def image_util_parse_metadata(db_metadata: Any) -> Mapping[str, Any]: + """ + Safely parses metadata from the database, which might be a JSON string or already a dict. + """ + if not db_metadata: + return {} + + if isinstance(db_metadata, str): + try: + parsed = json.loads(db_metadata) + return parsed if isinstance(parsed, dict) else {} + except (json.JSONDecodeError, TypeError): + return {} + + if isinstance(db_metadata, dict): + return db_metadata + + return {} diff --git a/backend/requirements.txt b/backend/requirements.txt index d8b54a1cc..00bec243a 100644 Binary files a/backend/requirements.txt and b/backend/requirements.txt differ diff --git a/backend/tests/test_face_clusters.py b/backend/tests/test_face_clusters.py index 977ee8c33..1e6f7c398 100644 --- a/backend/tests/test_face_clusters.py +++ b/backend/tests/test_face_clusters.py @@ -64,7 +64,7 @@ def sample_cluster_images(): "image_id": "img_1", "image_path": "/path/to/image1.jpg", "thumbnail_path": "/path/to/thumb1.jpg", - "metadata": "{'camera': 'Canon'}", + "metadata": {"camera": "Canon"}, "face_id": 101, "confidence": 0.95, "bbox": {"x": 100, "y": 200, "width": 150, "height": 200}, @@ -73,7 +73,7 @@ def sample_cluster_images(): "image_id": "img_2", "image_path": "/path/to/image2.jpg", "thumbnail_path": "/path/to/thumb2.jpg", - "metadata": "{'camera': 'Nikon'}", + "metadata": {"camera": "Nikon"}, "face_id": 102, "confidence": 0.87, "bbox": {"x": 50, "y": 100, "width": 120, "height": 160}, diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index 321a53b23..b65874f03 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -1931,8 +1931,7 @@ "title": "Thumbnailpath" }, "metadata": { - "type": "string", - "title": "Metadata" + "$ref": "#/components/schemas/MetadataModel" }, "isTagged": { "type": "boolean", @@ -2004,7 +2003,7 @@ "metadata": { "anyOf": [ { - "type": "string" + "type": "object" }, { "type": "null" @@ -2058,6 +2057,89 @@ "title": "ImageInCluster", "description": "Represents an image that contains faces from a specific cluster." }, + "MetadataModel": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "date_created": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Date Created" + }, + "width": { + "type": "integer", + "title": "Width" + }, + "height": { + "type": "integer", + "title": "Height" + }, + "file_location": { + "type": "string", + "title": "File Location" + }, + "file_size": { + "type": "integer", + "title": "File Size" + }, + "item_type": { + "type": "string", + "title": "Item Type" + }, + "latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Longitude" + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Location" + } + }, + "type": "object", + "required": [ + "name", + "date_created", + "width", + "height", + "file_location", + "file_size", + "item_type" + ], + "title": "MetadataModel" + }, "RenameClusterData": { "properties": { "cluster_id": { diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index eb18735ab..8e90c0a43 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -8,3 +8,11 @@ if (typeof global.TextEncoder === 'undefined') { if (typeof global.TextDecoder === 'undefined') { global.TextDecoder = TextDecoder as unknown as typeof global.TextDecoder; } + +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +(global as any).ResizeObserver = ResizeObserver; diff --git a/frontend/src/App.css b/frontend/src/App.css index 3c2c6926a..609c22cfc 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -196,3 +196,19 @@ letter-spacing: var(--tracking-normal); } } + +.hide-scrollbar::-webkit-scrollbar { + display: none; +} + +.hide-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +.no-select { + -webkit-user-select: none; /* Safari */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* IE10+/Edge */ + user-select: none; /* Standard */ +} diff --git a/frontend/src/components/Media/ChronologicalGallery.tsx b/frontend/src/components/Media/ChronologicalGallery.tsx new file mode 100644 index 000000000..3460d8ba7 --- /dev/null +++ b/frontend/src/components/Media/ChronologicalGallery.tsx @@ -0,0 +1,173 @@ +import { useMemo, useRef, useEffect, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { ImageCard } from '@/components/Media/ImageCard'; +import { Image } from '@/types/Media'; +import { selectImages } from '@/features/imageSelectors'; +import { + groupImagesByYearMonthFromMetadata, + createImageIndexMap, +} from '@/utils/dateUtils'; + +export type MonthMarker = { + offset: number; + month: string; + year: string; +}; + +type ChronologicalGalleryProps = { + images: Image[]; + showTitle?: boolean; + title?: string; + className?: string; + onMonthOffsetsChange?: (markers: MonthMarker[]) => void; + scrollContainerRef?: React.RefObject; +}; + +export const ChronologicalGallery = ({ + images, + showTitle = false, + title = 'Image Gallery', + className = '', + onMonthOffsetsChange, + scrollContainerRef, +}: ChronologicalGalleryProps) => { + const allImages = useSelector(selectImages); + const monthHeaderRefs = useRef>(new Map()); + const galleryRef = useRef(null); + + // Optimized grouping with proper date handling + const grouped = useMemo( + () => groupImagesByYearMonthFromMetadata(images), + [images], + ); + + // Optimized image index lookup + const imageIndexMap = useMemo( + () => createImageIndexMap(allImages), + [allImages], + ); + + const sortedGrouped = useMemo(() => { + return Object.entries(grouped) + .sort((a, b) => Number(b[0]) - Number(a[0])) + .map(([year, months]) => ({ + year, + months: Object.entries(months).sort( + (a, b) => Number(b[0]) - Number(a[0]), + ), + })); + }, [grouped]); + + const recomputeMarkers = useCallback(() => { + if (!onMonthOffsetsChange) return; + if (monthHeaderRefs.current.size === 0) { + onMonthOffsetsChange([]); + return; + } + + const scroller = scrollContainerRef?.current; + const scrollerTop = scroller ? scroller.getBoundingClientRect().top : 0; + + const entries = Array.from(monthHeaderRefs.current.entries()).flatMap( + ([key, el]) => { + if (!el) return []; + const [y, m] = key.split('-'); + const monthName = new Date(Number(y), Number(m) - 1).toLocaleString( + 'default', + { month: 'long' }, + ); + const offset = scroller + ? el.getBoundingClientRect().top - scrollerTop + scroller.scrollTop + : el.offsetTop; + return [{ offset, month: monthName, year: y }]; + }, + ); + entries.sort((a, b) => a.offset - b.offset); + onMonthOffsetsChange(entries); + }, [onMonthOffsetsChange, scrollContainerRef]); + + useEffect(() => { + recomputeMarkers(); + }, [images, recomputeMarkers]); + + useEffect(() => { + const elementToObserve = scrollContainerRef?.current ?? galleryRef.current; + if (!elementToObserve) return; + + const observer = new ResizeObserver(recomputeMarkers); + + observer.observe(elementToObserve); + + return () => { + observer.disconnect(); + }; + }, [recomputeMarkers, scrollContainerRef]); + + return ( +
+ {/* Title */} + {showTitle && ( +
+

{title}

+
+ )} + + {/* Gallery Content */} + {sortedGrouped.map(({ year, months }) => ( +
+ {months.map(([month, imgs]) => { + const monthName = new Date( + Number(year), + Number(month) - 1, + ).toLocaleString('default', { month: 'long' }); + + return ( +
{ + const key = `${year}-${month}`; + if (el) { + monthHeaderRefs.current.set(key, el); + } else { + monthHeaderRefs.current.delete(key); + } + }} + > + {/* Sticky Month/Year Header */} +
+

+
+ {monthName} {year} +
+ {imgs.length} {imgs.length === 1 ? 'image' : 'images'} +
+

+
+ + {/* Images Grid */} +
+ {imgs.map((img) => { + const reduxIndex = imageIndexMap.get(img.id) ?? -1; + + return ( +
+ +
+ ); + })} +
+
+ ); + })} +
+ ))} +
+ ); +}; diff --git a/frontend/src/components/Media/MediaInfoPanel.tsx b/frontend/src/components/Media/MediaInfoPanel.tsx index 93e808fa6..c192aef4b 100644 --- a/frontend/src/components/Media/MediaInfoPanel.tsx +++ b/frontend/src/components/Media/MediaInfoPanel.tsx @@ -7,6 +7,7 @@ import { MapPin, Tag, Info, + SquareArrowOutUpRight, } from 'lucide-react'; import { Image } from '@/types/Media'; @@ -26,11 +27,17 @@ export const MediaInfoPanel: React.FC = ({ totalImages, }) => { const getFormattedDate = () => { - return new Date().toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); + if (currentImage?.metadata?.date_created) { + return new Date(currentImage.metadata.date_created).toLocaleDateString( + 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric', + }, + ); + } + return 'Date not available'; }; const getImageName = () => { @@ -39,6 +46,18 @@ export const MediaInfoPanel: React.FC = ({ return currentImage.path?.split(/[/\\]/).pop() || 'Image'; }; + const handleLocationClick = async () => { + if (currentImage?.metadata?.latitude && currentImage?.metadata?.longitude) { + const { latitude, longitude } = currentImage.metadata; + const url = `https://maps.google.com/?q=${latitude},${longitude}`; + try { + await open(url); + } catch (error) { + console.error('Failed to open map URL:', error); + } + } + }; + if (!show) return null; return ( @@ -86,9 +105,20 @@ export const MediaInfoPanel: React.FC = ({

Location

-

- {currentImage?.metadata || 'No location data'} -

+ {currentImage?.metadata?.latitude && + currentImage?.metadata?.longitude ? ( + + ) : ( +

Location not available

+ )}
diff --git a/frontend/src/components/Timeline/TimelineScrollbar.tsx b/frontend/src/components/Timeline/TimelineScrollbar.tsx new file mode 100644 index 000000000..d2d144377 --- /dev/null +++ b/frontend/src/components/Timeline/TimelineScrollbar.tsx @@ -0,0 +1,409 @@ +import { useEffect, useMemo, useRef, useState, RefObject } from 'react'; +import { + useScroll, + useWheel, + getMarkerForScrollPosition, + TooltipState, +} from '@/utils/timelineUtils'; +import { + Tooltip, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { MonthMarker } from '@/components/Media/ChronologicalGallery'; + +const monthAbbreviations: Record = { + January: 'Jan', + February: 'Feb', + March: 'Mar', + April: 'Apr', + May: 'May', + June: 'Jun', + July: 'Jul', + August: 'Aug', + September: 'Sept', + October: 'Oct', + November: 'Nov', + December: 'Dec', +}; + +const abbreviateMonth = (month: string) => monthAbbreviations[month] ?? month; + +type TimelineScrollbarProps = { + className?: string; + scrollableRef: RefObject; + monthMarkers?: MonthMarker[]; +}; + +export default function TimelineScrollbar({ + className = '', + scrollableRef, + monthMarkers = [], +}: TimelineScrollbarProps) { + const containerRef = useRef(null); + const trackRef = useRef(null); + const [trackHeight, setTrackHeight] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const [isMarkerHovered, setIsMarkerHovered] = useState(false); + const [trackTooltip, setTrackTooltip] = useState(null); + const [isTrackTooltipVisible, setIsTrackTooltipVisible] = useState(false); + const [scrollTooltip, setScrollTooltip] = useState(null); + const [isScrollTooltipVisible, setIsScrollTooltipVisible] = useState(false); + const [dragTooltip, setDragTooltip] = useState< + (TooltipState & { visible: boolean }) | null + >(null); + const scrollProgress = useScroll(scrollableRef); + const scrollTooltipTimer = useRef | null>(null); + const [scrollableDimensions, setScrollableDimensions] = useState({ + scrollHeight: 0, + clientHeight: 0, + }); + + useEffect(() => { + const scrollable = scrollableRef.current; + if (!scrollable) return; + + const measure = () => { + setScrollableDimensions({ + scrollHeight: scrollable.scrollHeight, + clientHeight: scrollable.clientHeight, + }); + }; + + const resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(scrollable); + + measure(); // Initial measurement + + return () => resizeObserver.disconnect(); + }, [scrollableRef]); + + const markerPositions = useMemo(() => { + if (!scrollableRef.current || !monthMarkers.length) { + return []; + } + + const { scrollHeight, clientHeight } = scrollableDimensions; + const scrollableHeight = scrollHeight - clientHeight; + + // Filter out markers that can't reach the top of the viewport + const visibleMarkers = monthMarkers.filter( + (marker) => marker.offset <= scrollableHeight, + ); + + const safeDenominator = Math.max(1, scrollableHeight); + const maxTop = trackHeight; + + return visibleMarkers.map((marker) => { + const markerTop = (marker.offset / safeDenominator) * trackHeight; + return { + ...marker, + markerTop: Math.max(0, Math.min(markerTop, maxTop)), + }; + }); + }, [monthMarkers, trackHeight, scrollableDimensions, scrollableRef]); + + useWheel(containerRef, (deltaY) => { + const scroller = scrollableRef.current; + if (scroller) scroller.scrollTop += deltaY; + }); + + // Effect to show/hide scroll tooltip + useEffect(() => { + if (isDragging || isMarkerHovered || !scrollableRef.current) { + setIsScrollTooltipVisible(false); + return; + } + + if (scrollTooltipTimer.current) { + clearTimeout(scrollTooltipTimer.current); + } + + const scrollable = scrollableRef.current; + const currentMarker = getMarkerForScrollPosition( + scrollable.scrollTop, + monthMarkers, + ); + + if (currentMarker) { + const tooltipHalfHeight = 14; + const top = Math.max( + tooltipHalfHeight, + Math.min(scrollProgress * trackHeight, trackHeight - tooltipHalfHeight), + ); + + setScrollTooltip({ + top, + month: currentMarker.month, + year: currentMarker.year, + }); + setIsScrollTooltipVisible(true); + } + + scrollTooltipTimer.current = setTimeout(() => { + setIsScrollTooltipVisible(false); + }, 1000); + + return () => { + if (scrollTooltipTimer.current) { + clearTimeout(scrollTooltipTimer.current); + } + }; + }, [ + scrollProgress, + isDragging, + isMarkerHovered, + monthMarkers, + scrollableRef, + trackHeight, + ]); + + // Measure parent height dynamically + useEffect(() => { + if (!containerRef.current?.parentElement) return; + + const parent = containerRef.current.parentElement; + const measure = () => setTrackHeight(parent.clientHeight); + + measure(); + + const resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(parent); + + window.addEventListener('resize', measure); + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', measure); + }; + }, []); + + const handleScroll = (clientY: number) => { + if (!scrollableRef.current || !trackRef.current) return; + + const { top, height } = trackRef.current.getBoundingClientRect(); + const clickY = clientY - top; + const scrollPercentage = Math.min(Math.max(clickY / height, 0), 1); + + const scrollable = scrollableRef.current; + const scrollToY = + scrollPercentage * (scrollable.scrollHeight - scrollable.clientHeight); + + scrollable.scrollTo({ + top: scrollToY, + behavior: 'auto', + }); + }; + + const updateDragTooltip = (clientY: number) => { + if (!trackRef.current || !scrollableRef.current || !monthMarkers.length) + return; + + const { top, height } = trackRef.current.getBoundingClientRect(); + const dragY = clientY - top; + + const scrollable = scrollableRef.current; + const scrollPercentage = Math.min(Math.max(dragY / height, 0), 1); + const correspondingScrollTop = + scrollPercentage * (scrollable.scrollHeight - scrollable.clientHeight); + + const draggedMarker = getMarkerForScrollPosition( + correspondingScrollTop, + monthMarkers, + ); + + if (draggedMarker) { + const tooltipHalfHeight = 14; + const clampedDragY = Math.max( + tooltipHalfHeight, + Math.min(dragY, height - tooltipHalfHeight), + ); + setDragTooltip({ + visible: true, + top: clampedDragY, + month: draggedMarker.month, + year: draggedMarker.year, + }); + } + }; + + const handleMarkerClick = (offset: number) => { + if (!scrollableRef.current) return; + + scrollableRef.current.scrollTo({ + top: offset, + behavior: 'smooth', + }); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + setIsDragging(true); + document.body.classList.add('no-select'); + handleScroll(e.clientY); + updateDragTooltip(e.clientY); + + const handleMouseMove = (e: MouseEvent) => { + try { + handleScroll(e.clientY); + updateDragTooltip(e.clientY); + } catch (error) { + console.error('Error during drag:', error); + handleMouseUp(); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.body.classList.remove('no-select'); + setDragTooltip(null); + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + window.removeEventListener('blur', handleMouseUp); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + window.addEventListener('blur', handleMouseUp); + }; + + const handleTrackMouseMove = (e: React.MouseEvent) => { + if (isDragging || isMarkerHovered) { + setIsTrackTooltipVisible(false); + return; + } + + if (!trackRef.current) return; + const { clientY } = e; + const { top, height } = trackRef.current.getBoundingClientRect(); + const hoverY = clientY - top; + + const scrollable = scrollableRef.current; + if (!scrollable) return; + + const scrollPercentage = Math.min(Math.max(hoverY / height, 0), 1); + const correspondingScrollTop = + scrollPercentage * (scrollable.scrollHeight - scrollable.clientHeight); + + const hoveredMarker = getMarkerForScrollPosition( + correspondingScrollTop, + monthMarkers, + ); + + if (hoveredMarker) { + const tooltipHalfHeight = 14; + const top = Math.max( + tooltipHalfHeight, + Math.min(hoverY, height - tooltipHalfHeight), + ); + setTrackTooltip({ + top, + month: hoveredMarker.month, + year: hoveredMarker.year, + }); + setIsTrackTooltipVisible(true); + } else { + setIsTrackTooltipVisible(false); + } + }; + + const handleTrackMouseLeave = () => { + setIsTrackTooltipVisible(false); + }; + + const tooltipBaseClass = + 'text-primary-foreground bg-primary absolute left-[-75px] rounded-md px-2 py-1 text-xs'; + + return ( +
+ {/* Timeline Track */} +
+ {/* Progress Fill */} +
+ + {/* Month Markers */} + + {markerPositions.map((marker, index) => { + return ( + + +
handleMarkerClick(marker.offset)} + onMouseEnter={() => !isDragging && setIsMarkerHovered(true)} + onMouseLeave={() => setIsMarkerHovered(false)} + /> + + + ); + })} + + + {/* Track Hover Tooltip */} + {trackTooltip && ( +
+ {`${abbreviateMonth(trackTooltip.month)} ${trackTooltip.year}`} +
+ )} + + {/* Scroll Tooltip */} + {scrollTooltip && !isTrackTooltipVisible && ( +
+ {`${abbreviateMonth(scrollTooltip.month)} ${scrollTooltip.year}`} +
+ )} + + {/* Drag Tooltip */} + {isDragging && dragTooltip && dragTooltip.visible && ( +
+ {`${abbreviateMonth(dragTooltip.month)} ${dragTooltip.year}`} +
+ )} +
+
+ ); +} diff --git a/frontend/src/layout/layout.tsx b/frontend/src/layout/layout.tsx index cfe363518..e3ba980ae 100644 --- a/frontend/src/layout/layout.tsx +++ b/frontend/src/layout/layout.tsx @@ -9,7 +9,7 @@ const Layout: React.FC = () => {
-
+
diff --git a/frontend/src/pages/AITagging/AITagging.tsx b/frontend/src/pages/AITagging/AITagging.tsx index 8e278918c..1f0f5c9d6 100644 --- a/frontend/src/pages/AITagging/AITagging.tsx +++ b/frontend/src/pages/AITagging/AITagging.tsx @@ -1,6 +1,5 @@ -import { useEffect } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { ImageCard } from '@/components/Media/ImageCard'; import { MediaView } from '@/components/Media/MediaView'; import { FaceCollections } from '@/components/FaceCollections'; import { Image } from '@/types/Media'; @@ -12,11 +11,18 @@ import { } from '@/features/imageSelectors'; import { usePictoQuery } from '@/hooks/useQueryExtension'; import { fetchAllImages } from '@/api/api-functions'; +import { + ChronologicalGallery, + MonthMarker, +} from '@/components/Media/ChronologicalGallery'; +import TimelineScrollbar from '@/components/Timeline/TimelineScrollbar'; export const AITagging = () => { const dispatch = useDispatch(); const isImageViewOpen = useSelector(selectIsImageViewOpen); const taggedImages = useSelector(selectTaggedImages); + const scrollableRef = useRef(null); + const [monthMarkers, setMonthMarkers] = useState([]); const { data: imagesData, @@ -41,31 +47,39 @@ export const AITagging = () => { }, [imagesData, imagesSuccess, imagesError, imagesLoading, dispatch]); return ( -
-

AI Tagging

+
+
+

AI Tagging

- {/* Face Collections Section */} -
- -
+ {/* Face Collections Section */} +
+ +
- {/* Image Grid */} -
-

All Images

-
- {taggedImages.map((image, index) => ( - - ))} + {/* Gallery Section */} +
+
-
- {/* Media Viewer Modal */} - {isImageViewOpen && } + {/* Media Viewer Modal */} + {isImageViewOpen && } +
+ {monthMarkers.length > 0 && ( + + )}
); }; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 7ba860849..0bae40d12 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -1,6 +1,10 @@ -import { useEffect } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { ImageCard } from '@/components/Media/ImageCard'; +import { + ChronologicalGallery, + MonthMarker, +} from '@/components/Media/ChronologicalGallery'; +import TimelineScrollbar from '@/components/Timeline/TimelineScrollbar'; import { MediaView } from '@/components/Media/MediaView'; import { Image } from '@/types/Media'; import { setImages } from '@/features/imageSlice'; @@ -13,9 +17,10 @@ import { showInfoDialog } from '@/features/infoDialogSlice'; export const Home = () => { const dispatch = useDispatch(); - const isImageViewOpen = useSelector(selectIsImageViewOpen); const images = useSelector(selectImages); + const scrollableRef = useRef(null); + const [monthMarkers, setMonthMarkers] = useState([]); const searchState = useSelector((state: RootState) => state.search); const isSearchActive = searchState.active; @@ -61,22 +66,31 @@ export const Home = () => { : 'Image Gallery'; return ( -
-

{title}

- - {/* Image Grid */} -
- {displayImages.map((image, index) => ( - - ))} +
+ {/* Gallery Section */} +
+
- {/* Media Viewer Modal */} + {/* Timeline Scrollbar */} + {monthMarkers.length > 0 && ( + + )} + + {/* Media viewer modal */} {isImageViewOpen && ( )} diff --git a/frontend/src/types/Media.ts b/frontend/src/types/Media.ts index f5fa7158a..02ea57771 100644 --- a/frontend/src/types/Media.ts +++ b/frontend/src/types/Media.ts @@ -1,10 +1,22 @@ +export interface ImageMetadata { + name: string; + date_created: string | null; + width: number; + height: number; + file_location: string; + file_size: number; + item_type: string; + latitude?: number; + longitude?: number; +} + export interface Image { id: string; path: string; thumbnailPath: string; folder_id: string; isTagged: boolean; - metadata?: string; + metadata?: ImageMetadata; tags?: string[]; bboxes?: { x: number; y: number; width: number; height: number }[]; } diff --git a/frontend/src/utils/dateUtils.ts b/frontend/src/utils/dateUtils.ts index 41012bfa8..bd8b7d30b 100644 --- a/frontend/src/utils/dateUtils.ts +++ b/frontend/src/utils/dateUtils.ts @@ -1,3 +1,5 @@ +import { Image } from '@/types/Media'; + export const getTimeAgo = (dateString: string): string => { const date = new Date(dateString); const now = new Date(); @@ -24,3 +26,41 @@ export const getTimeAgo = (dateString: string): string => { return 'a long time ago'; }; + +// To group Images from same Month & Year. +export const groupImagesByYearMonthFromMetadata = (images: Image[]) => { + const grouped: Record> = {}; + + images.forEach((image) => { + const dateStr = image.metadata?.date_created; // extract date from metadata.date_created + if (!dateStr) return; + + const date = new Date(dateStr); + if (isNaN(date.getTime())) return; + + const year = date.getFullYear().toString(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + + if (!grouped[year]) { + grouped[year] = {}; + } + if (!grouped[year][month]) { + grouped[year][month] = []; + } + + grouped[year][month].push(image); + }); + + return grouped; +}; + +// Build a fast lookup map between an image's id and its index in the global array +export const createImageIndexMap = ( + allImages: Image[], +): Map => { + const indexMap = new Map(); + allImages.forEach((image, index) => { + indexMap.set(image.id, index); + }); + return indexMap; +}; diff --git a/frontend/src/utils/timelineUtils.ts b/frontend/src/utils/timelineUtils.ts new file mode 100644 index 000000000..0eb21fe85 --- /dev/null +++ b/frontend/src/utils/timelineUtils.ts @@ -0,0 +1,79 @@ +import { useState, useEffect, RefObject } from 'react'; +import { MonthMarker } from '@/components/Media/ChronologicalGallery'; + +export type TooltipState = { + top: number; + month: string; + year: string; +}; + +export function useScroll(scrollableRef: RefObject) { + const [scrollProgress, setScrollProgress] = useState(0); + + useEffect(() => { + const scrollable = scrollableRef.current; + if (!scrollable) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = scrollable; + const denom = Math.max(scrollHeight - clientHeight, 1); + const progress = Math.min(1, Math.max(0, scrollTop / denom)); + setScrollProgress(progress); + }; + + scrollable.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + scrollable.removeEventListener('scroll', handleScroll); + }; + }, [scrollableRef]); + + return scrollProgress; +} + +export function useWheel( + ref: RefObject, + callback: (deltaY: number) => void, +) { + useEffect(() => { + const element = ref.current; + if (!element) return; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + callback(e.deltaY); + }; + + element.addEventListener('wheel', handleWheel, { passive: false }); + + return () => { + element.removeEventListener('wheel', handleWheel); + }; + }, [ref, callback]); +} + +export const getMarkerForScrollPosition = ( + scrollPosition: number, + monthMarkers: MonthMarker[], +): MonthMarker | undefined => { + if (!monthMarkers.length) return undefined; + const sorted = monthMarkers.slice().sort((a, b) => a.offset - b.offset); + let result: MonthMarker | undefined = undefined; + for (const m of sorted) { + if (m.offset <= scrollPosition) result = m; + else break; + } + return result; +}; + +export const clamp = (value: number, min: number, max: number): number => { + return Math.max(min, Math.min(value, max)); +}; + +export const calculateClampedTooltipTop = ( + position: number, + trackHeight: number, + tooltipHalfHeight: number = 14, +): number => { + return clamp(position, tooltipHalfHeight, trackHeight - tooltipHalfHeight); +};