diff --git a/README.md b/README.md index 595889480..a77b120ba 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Handles file system operations and provides a secure bridge between the frontend - Smart tagging of photos based on detected objects, faces, and their recognition - Traditional gallery features of album management +- **Memories**: Auto-generated photo memories grouped by time and location (similar to Google Photos "On this day") - Advanced image analysis with object detection and facial recognition - Privacy-focused design with offline functionality - Efficient data handling and parallel processing diff --git a/backend/app/routes/memories.py b/backend/app/routes/memories.py new file mode 100644 index 000000000..e3a170db0 --- /dev/null +++ b/backend/app/routes/memories.py @@ -0,0 +1,171 @@ +""" +Memories API routes for retrieving auto-generated photo memories. +""" + +from fastapi import APIRouter, HTTPException, status, Query +from typing import List, Optional +from pydantic import BaseModel +from app.utils.memories import generate_memories +from app.schemas.images import ErrorResponse +from app.logging.setup_logging import get_logger + +logger = get_logger(__name__) +router = APIRouter() + + +# Response Models +class RepresentativeMedia(BaseModel): + """Representative media thumbnail for a memory.""" + id: str + thumbnailPath: str + + +class DateRange(BaseModel): + """Date range for a memory.""" + start: str + end: str + + +class Memory(BaseModel): + """A memory object containing clustered photos.""" + id: str + title: str + type: str # "on_this_day", "trip", "date_cluster", etc. + date_range: DateRange + location: Optional[str] = None + media_count: int + representative_media: List[RepresentativeMedia] + media_ids: List[str] + + +class GetMemoriesResponse(BaseModel): + """Response model for GET /memories endpoint.""" + success: bool + message: str + data: List[Memory] + + +@router.get( + "/", + response_model=GetMemoriesResponse, + responses={500: {"model": ErrorResponse}}, +) +def get_memories( + limit: Optional[int] = Query(None, description="Maximum number of memories to return", ge=1, le=100) +): + """ + Get all auto-generated memories. + + Memories are automatically generated by clustering photos based on: + - Date similarity (same day, month, year, or "on this day" from past years) + - Geographic proximity (nearby locations) + + Returns memories sorted by date (most recent first). + """ + try: + memories = generate_memories() + + # Apply limit if specified + if limit is not None: + memories = memories[:limit] + + # Convert to response models + memory_models = [ + Memory( + id=mem["id"], + title=mem["title"], + type=mem["type"], + date_range=DateRange( + start=mem["date_range"]["start"], + end=mem["date_range"]["end"], + ), + location=mem.get("location"), + media_count=mem["media_count"], + representative_media=[ + RepresentativeMedia( + id=media["id"], + thumbnailPath=media["thumbnailPath"], + ) + for media in mem["representative_media"] + ], + media_ids=mem["media_ids"], + ) + for mem in memories + ] + + return GetMemoriesResponse( + success=True, + message=f"Successfully retrieved {len(memory_models)} memories", + data=memory_models, + ) + + except Exception as e: + logger.error(f"Error retrieving memories: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, + error="Internal server error", + message=f"Unable to retrieve memories: {str(e)}", + ).model_dump(), + ) + + +@router.get( + "/{memory_id}", + response_model=Memory, + responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, +) +def get_memory_by_id(memory_id: str): + """ + Get a specific memory by ID. + """ + try: + memories = generate_memories() + + # Find memory by ID + memory = next((m for m in memories if m["id"] == memory_id), None) + + if not memory: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ErrorResponse( + success=False, + error="Not Found", + message=f"Memory with ID '{memory_id}' not found", + ).model_dump(), + ) + + return Memory( + id=memory["id"], + title=memory["title"], + type=memory["type"], + date_range=DateRange( + start=memory["date_range"]["start"], + end=memory["date_range"]["end"], + ), + location=memory.get("location"), + media_count=memory["media_count"], + representative_media=[ + RepresentativeMedia( + id=media["id"], + thumbnailPath=media["thumbnailPath"], + ) + for media in memory["representative_media"] + ], + media_ids=memory["media_ids"], + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error retrieving memory {memory_id}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, + error="Internal server error", + message=f"Unable to retrieve memory: {str(e)}", + ).model_dump(), + ) + diff --git a/backend/app/utils/memories.py b/backend/app/utils/memories.py new file mode 100644 index 000000000..a670f9593 --- /dev/null +++ b/backend/app/utils/memories.py @@ -0,0 +1,366 @@ +""" +Memory generation utility for clustering photos by date and location. + +This module generates memories by: +1. Clustering photos by date similarity (same day, month, year, or "on this day" from past years) +2. Clustering photos by geographic proximity +3. Creating memory objects with representative media subsets +""" + +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional, Tuple +from collections import defaultdict +import math +from app.database.images import db_get_all_images +from app.utils.images import image_util_parse_metadata +from app.logging.setup_logging import get_logger + +logger = get_logger(__name__) + +# Constants for clustering +DATE_CLUSTER_THRESHOLD_DAYS = 7 # Photos within 7 days are considered same event +LOCATION_CLUSTER_THRESHOLD_KM = 10 # Photos within 10km are considered same location +REPRESENTATIVE_MEDIA_COUNT = 6 # Number of thumbnails to show per memory + + +def _haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """ + Calculate the great circle distance between two points on Earth in kilometers. + Uses the Haversine formula. + """ + # Earth radius in kilometers + R = 6371.0 + + # Convert latitude and longitude from degrees to radians + lat1_rad = math.radians(lat1) + lon1_rad = math.radians(lon1) + lat2_rad = math.radians(lat2) + lon2_rad = math.radians(lon2) + + # Haversine formula + dlat = lat2_rad - lat1_rad + dlon = lon2_rad - lon1_rad + + a = ( + math.sin(dlat / 2) ** 2 + + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2 + ) + c = 2 * math.asin(math.sqrt(a)) + + return R * c + + +def _parse_date(date_str: Optional[str]) -> Optional[datetime]: + """Parse ISO date string to datetime object.""" + if not date_str: + return None + try: + # Handle ISO format with or without microseconds + if "T" in date_str: + date_str = date_str.split("T")[0] + " " + date_str.split("T")[1].split(".")[0] + return datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S") + except (ValueError, AttributeError): + try: + # Try just date + return datetime.strptime(date_str.split(" ")[0], "%Y-%m-%d") + except (ValueError, AttributeError): + return None + + +def _format_memory_title( + memory_type: str, date: Optional[datetime], location: Optional[str] +) -> str: + """ + Generate a human-readable title for a memory. + + Examples: + - "On this day, 2023" + - "Trip to Jaipur, 2023" + - "December 2023" + - "Summer 2023" + """ + if memory_type == "on_this_day" and date: + return f"On this day, {date.year}" + elif memory_type == "trip" and location and date: + return f"Trip to {location}, {date.year}" + elif memory_type == "trip" and date: + return f"Trip, {date.strftime('%B %Y')}" + elif memory_type == "date_cluster" and date: + # Same day + today = datetime.now() + if date.date() == today.date(): + return "Today" + elif date.date() == (today - timedelta(days=1)).date(): + return "Yesterday" + else: + return date.strftime("%B %d, %Y") + elif memory_type == "month_cluster" and date: + return date.strftime("%B %Y") + elif memory_type == "year_cluster" and date: + return str(date.year) + else: + return "Memory" + + +def _get_location_name(lat: float, lon: float) -> str: + """ + Get a human-readable location name from coordinates. + For now, returns a simple format. Can be enhanced with reverse geocoding. + """ + # Simple format: "Location (lat, lon)" + # In production, this could use a reverse geocoding service + return f"Location ({lat:.4f}, {lon:.4f})" + + +def _cluster_by_date(images: List[Dict[str, Any]]) -> List[List[Dict[str, Any]]]: + """ + Cluster images by date similarity. + Returns list of clusters, each containing images from similar dates. + """ + # Group by date (same day) + date_groups = defaultdict(list) + for img in images: + metadata = img.get("metadata", {}) + date_str = metadata.get("date_created") + date = _parse_date(date_str) + if date: + date_key = date.date() # Group by date only + date_groups[date_key].append(img) + + # Convert to list of clusters + clusters = [] + for date_key, imgs in date_groups.items(): + if len(imgs) >= 2: # Only create clusters with 2+ images + clusters.append(imgs) + + return clusters + + +def _cluster_by_location(images: List[Dict[str, Any]]) -> List[List[Dict[str, Any]]]: + """ + Cluster images by geographic proximity. + Uses simple distance-based clustering. + """ + # Filter images with valid GPS coordinates + geo_images = [] + for img in images: + metadata = img.get("metadata", {}) + lat = metadata.get("latitude") + lon = metadata.get("longitude") + if lat is not None and lon is not None: + geo_images.append((img, lat, lon)) + + if len(geo_images) < 2: + return [] + + # Simple clustering: group images that are close to each other + clusters = [] + used = set() + + for i, (img1, lat1, lon1) in enumerate(geo_images): + if i in used: + continue + + cluster = [img1] + used.add(i) + + for j, (img2, lat2, lon2) in enumerate(geo_images): + if j in used or i == j: + continue + + distance = _haversine_distance(lat1, lon1, lat2, lon2) + if distance <= LOCATION_CLUSTER_THRESHOLD_KM: + cluster.append(img2) + used.add(j) + + if len(cluster) >= 2: + clusters.append(cluster) + + return clusters + + +def _get_on_this_day_memories(images: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Generate "On this day" memories - photos from the same date in past years. + """ + today = datetime.now() + memories = [] + + # Group images by month-day (ignoring year) + month_day_groups = defaultdict(list) + for img in images: + metadata = img.get("metadata", {}) + date_str = metadata.get("date_created") + date = _parse_date(date_str) + if date and date.year < today.year: # Only past years + month_day_key = (date.month, date.day) + month_day_groups[month_day_key].append((img, date)) + + # Check if today's month-day matches any past year + today_key = (today.month, today.day) + if today_key in month_day_groups: + past_images = month_day_groups[today_key] + if len(past_images) >= 2: + # Get the most recent year's images + past_images.sort(key=lambda x: x[1], reverse=True) + images_for_memory = [img for img, _ in past_images[:20]] # Limit to 20 + + # Get representative subset + representative = images_for_memory[:REPRESENTATIVE_MEDIA_COUNT] + + # Get date range + dates = [date for _, date in past_images] + min_date = min(dates) + max_date = max(dates) + + memory = { + "id": f"on_this_day_{min_date.year}", + "title": _format_memory_title("on_this_day", min_date, None), + "type": "on_this_day", + "date_range": { + "start": min_date.isoformat(), + "end": max_date.isoformat(), + }, + "location": None, + "media_count": len(images_for_memory), + "representative_media": [ + { + "id": img["id"], + "thumbnailPath": img["thumbnailPath"], + } + for img in representative + ], + "media_ids": [img["id"] for img in images_for_memory], + } + memories.append(memory) + + return memories + + +def _create_memory_from_cluster( + cluster: List[Dict[str, Any]], + memory_type: str, + cluster_id: str, +) -> Dict[str, Any]: + """ + Create a memory object from a cluster of images. + """ + if not cluster: + return None + + # Get dates from cluster + dates = [] + locations = [] + for img in cluster: + metadata = img.get("metadata", {}) + date_str = metadata.get("date_created") + date = _parse_date(date_str) + if date: + dates.append(date) + + lat = metadata.get("latitude") + lon = metadata.get("longitude") + if lat is not None and lon is not None: + locations.append((lat, lon)) + + # Determine date range + if dates: + min_date = min(dates) + max_date = max(dates) + else: + min_date = max_date = datetime.now() + + # Determine location (average if multiple) + location = None + if locations: + avg_lat = sum(lat for lat, _ in locations) / len(locations) + avg_lon = sum(lon for _, lon in locations) / len(locations) + location = _get_location_name(avg_lat, avg_lon) + + # Get representative subset + representative = cluster[:REPRESENTATIVE_MEDIA_COUNT] + + return { + "id": cluster_id, + "title": _format_memory_title(memory_type, min_date, location), + "type": memory_type, + "date_range": { + "start": min_date.isoformat(), + "end": max_date.isoformat(), + }, + "location": location, + "media_count": len(cluster), + "representative_media": [ + { + "id": img["id"], + "thumbnailPath": img["thumbnailPath"], + } + for img in representative + ], + "media_ids": [img["id"] for img in cluster], + } + + +def generate_memories() -> List[Dict[str, Any]]: + """ + Main function to generate all memories from images in the database. + Returns a list of memory objects. + """ + try: + # Get all images from database + all_images = db_get_all_images() + + if not all_images: + logger.info("No images found in database") + return [] + + # Parse metadata for all images + images_with_metadata = [] + for img in all_images: + metadata = image_util_parse_metadata(img.get("metadata")) + img["metadata"] = metadata + images_with_metadata.append(img) + + memories = [] + + # 1. Generate "On this day" memories + on_this_day = _get_on_this_day_memories(images_with_metadata) + memories.extend(on_this_day) + + # 2. Cluster by location (trips) + location_clusters = _cluster_by_location(images_with_metadata) + for idx, cluster in enumerate(location_clusters): + memory = _create_memory_from_cluster( + cluster, "trip", f"trip_{idx}" + ) + if memory: + memories.append(memory) + + # 3. Cluster by date (same day events) + date_clusters = _cluster_by_date(images_with_metadata) + for idx, cluster in enumerate(date_clusters): + # Skip if already in a location cluster + cluster_ids = {img["id"] for img in cluster} + already_in_memory = any( + any(img_id in m["media_ids"] for img_id in cluster_ids) + for m in memories + ) + if not already_in_memory: + memory = _create_memory_from_cluster( + cluster, "date_cluster", f"date_{idx}" + ) + if memory: + memories.append(memory) + + # Sort memories by date (most recent first) + memories.sort( + key=lambda m: m["date_range"]["end"], reverse=True + ) + + logger.info(f"Generated {len(memories)} memories") + return memories + + except Exception as e: + logger.error(f"Error generating memories: {e}", exc_info=True) + return [] + diff --git a/backend/main.py b/backend/main.py index 2c1f39e44..f55b2fd46 100644 --- a/backend/main.py +++ b/backend/main.py @@ -26,6 +26,7 @@ from app.routes.images import router as images_router from app.routes.face_clusters import router as face_clusters_router from app.routes.user_preferences import router as user_preferences_router +from app.routes.memories import router as memories_router from fastapi.openapi.utils import get_openapi from app.logging.setup_logging import ( configure_uvicorn_logging, @@ -132,6 +133,7 @@ async def root(): app.include_router( user_preferences_router, prefix="/user-preferences", tags=["User Preferences"] ) +app.include_router(memories_router, prefix="/memories", tags=["Memories"]) # Entry point for running with: python3 main.py diff --git a/backend/tests/test_memories.py b/backend/tests/test_memories.py new file mode 100644 index 000000000..76a25016a --- /dev/null +++ b/backend/tests/test_memories.py @@ -0,0 +1,159 @@ +""" +Tests for the Memories API endpoints. +""" + +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch, MagicMock +from app.main import app + +client = TestClient(app) + + +@pytest.fixture +def mock_images_with_metadata(): + """Sample images with metadata for testing.""" + return [ + { + "id": "img1", + "path": "/test/img1.jpg", + "folder_id": "1", + "thumbnailPath": "/thumb/img1.jpg", + "metadata": { + "date_created": "2023-06-15T10:00:00", + "latitude": 26.9124, + "longitude": 75.7873, + }, + "isTagged": True, + "isFavourite": False, + "tags": None, + }, + { + "id": "img2", + "path": "/test/img2.jpg", + "folder_id": "1", + "thumbnailPath": "/thumb/img2.jpg", + "metadata": { + "date_created": "2023-06-15T11:00:00", + "latitude": 26.9125, + "longitude": 75.7874, + }, + "isTagged": True, + "isFavourite": False, + "tags": None, + }, + { + "id": "img3", + "path": "/test/img3.jpg", + "folder_id": "1", + "thumbnailPath": "/thumb/img3.jpg", + "metadata": { + "date_created": "2022-06-15T10:00:00", + }, + "isTagged": True, + "isFavourite": False, + "tags": None, + }, + ] + + +@patch("app.routes.memories.generate_memories") +def test_get_memories_success(mock_generate_memories): + """Test successful retrieval of memories.""" + mock_memories = [ + { + "id": "memory1", + "title": "Trip to Jaipur, 2023", + "type": "trip", + "date_range": { + "start": "2023-06-15T10:00:00", + "end": "2023-06-15T11:00:00", + }, + "location": "Location (26.9124, 75.7873)", + "media_count": 2, + "representative_media": [ + {"id": "img1", "thumbnailPath": "/thumb/img1.jpg"}, + ], + "media_ids": ["img1", "img2"], + } + ] + mock_generate_memories.return_value = mock_memories + + response = client.get("/memories/") + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert len(data["data"]) == 1 + assert data["data"][0]["id"] == "memory1" + assert data["data"][0]["title"] == "Trip to Jaipur, 2023" + + +@patch("app.routes.memories.generate_memories") +def test_get_memories_with_limit(mock_generate_memories): + """Test getting memories with limit parameter.""" + mock_memories = [ + {"id": f"memory{i}", "title": f"Memory {i}", "type": "date_cluster", + "date_range": {"start": "2023-01-01", "end": "2023-01-01"}, + "location": None, "media_count": 1, + "representative_media": [], "media_ids": [f"img{i}"]} + for i in range(10) + ] + mock_generate_memories.return_value = mock_memories + + response = client.get("/memories/?limit=5") + assert response.status_code == 200 + data = response.json() + assert len(data["data"]) == 5 + + +@patch("app.routes.memories.generate_memories") +def test_get_memories_empty(mock_generate_memories): + """Test getting memories when none exist.""" + mock_generate_memories.return_value = [] + + response = client.get("/memories/") + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert len(data["data"]) == 0 + + +@patch("app.routes.memories.generate_memories") +def test_get_memory_by_id_success(mock_generate_memories): + """Test getting a specific memory by ID.""" + mock_memories = [ + { + "id": "memory1", + "title": "Trip to Jaipur, 2023", + "type": "trip", + "date_range": { + "start": "2023-06-15T10:00:00", + "end": "2023-06-15T11:00:00", + }, + "location": "Location (26.9124, 75.7873)", + "media_count": 2, + "representative_media": [ + {"id": "img1", "thumbnailPath": "/thumb/img1.jpg"}, + ], + "media_ids": ["img1", "img2"], + } + ] + mock_generate_memories.return_value = mock_memories + + response = client.get("/memories/memory1") + assert response.status_code == 200 + data = response.json() + assert data["id"] == "memory1" + assert data["title"] == "Trip to Jaipur, 2023" + + +@patch("app.routes.memories.generate_memories") +def test_get_memory_by_id_not_found(mock_generate_memories): + """Test getting a memory that doesn't exist.""" + mock_generate_memories.return_value = [] + + response = client.get("/memories/nonexistent") + assert response.status_code == 404 + data = response.json() + assert data["detail"]["error"] == "Not Found" + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e1e1ddd5f..5c4674b22 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -128,6 +128,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -780,6 +781,7 @@ "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1705,6 +1707,7 @@ "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-module-imports": "^7.27.1", @@ -5354,8 +5357,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5533,6 +5535,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5550,6 +5553,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5560,6 +5564,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5689,6 +5694,7 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -5935,6 +5941,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6641,6 +6648,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -7349,8 +7357,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/domexception": { "version": "4.0.0", @@ -7709,6 +7716,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9753,6 +9761,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -11237,7 +11246,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11924,6 +11932,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11956,6 +11965,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12059,7 +12069,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -12075,7 +12084,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -12195,6 +12203,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12253,6 +12262,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -12288,14 +12298,14 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -12457,7 +12467,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -13420,6 +13431,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13560,6 +13572,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13792,6 +13805,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14050,6 +14064,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -14182,6 +14197,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14476,20 +14492,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/frontend/src/api/api-functions/index.ts b/frontend/src/api/api-functions/index.ts index 5d6f2fa8c..4e22ef925 100644 --- a/frontend/src/api/api-functions/index.ts +++ b/frontend/src/api/api-functions/index.ts @@ -4,3 +4,4 @@ export * from './images'; export * from './folders'; export * from './user_preferences'; export * from './health'; +export * from './memories'; diff --git a/frontend/src/api/api-functions/memories.ts b/frontend/src/api/api-functions/memories.ts new file mode 100644 index 000000000..9e11fb1bb --- /dev/null +++ b/frontend/src/api/api-functions/memories.ts @@ -0,0 +1,23 @@ +import { memoriesEndpoints } from '../apiEndpoints'; +import { apiClient } from '../axiosConfig'; +import { APIResponse } from '@/types/API'; + +export const fetchAllMemories = async ( + limit?: number, +): Promise => { + const params = limit !== undefined ? { limit } : {}; + const response = await apiClient.get( + memoriesEndpoints.getAllMemories, + { params }, + ); + return response.data; +}; + +export const fetchMemoryById = async ( + memoryId: string, +): Promise => { + const response = await apiClient.get( + memoriesEndpoints.getMemoryById(memoryId), + ); + return response.data; +}; diff --git a/frontend/src/api/apiEndpoints.ts b/frontend/src/api/apiEndpoints.ts index 69a7e570d..e3d56ae78 100644 --- a/frontend/src/api/apiEndpoints.ts +++ b/frontend/src/api/apiEndpoints.ts @@ -30,3 +30,8 @@ export const userPreferencesEndpoints = { export const healthEndpoints = { healthCheck: '/health', }; + +export const memoriesEndpoints = { + getAllMemories: '/memories/', + getMemoryById: (memoryId: string) => `/memories/${memoryId}`, +}; diff --git a/frontend/src/components/EmptyStates/EmptyMemoriesState.tsx b/frontend/src/components/EmptyStates/EmptyMemoriesState.tsx new file mode 100644 index 000000000..392ccecbd --- /dev/null +++ b/frontend/src/components/EmptyStates/EmptyMemoriesState.tsx @@ -0,0 +1,32 @@ +import { Sparkles, Calendar, MapPin } from 'lucide-react'; + +export const EmptyMemoriesState = () => { + return ( +
+
+ +
+

+ No Memories Yet +

+

+ Memories are automatically generated from your photos based on time and + location. Add more photos to your gallery to see memories appear here. +

+
+
+ + Memories are grouped by date and time +
+
+ + Photos with location data create trip memories +
+
+ + Special moments are highlighted automatically +
+
+
+ ); +}; diff --git a/frontend/src/components/Memories/MemoryCard.tsx b/frontend/src/components/Memories/MemoryCard.tsx new file mode 100644 index 000000000..c24995e27 --- /dev/null +++ b/frontend/src/components/Memories/MemoryCard.tsx @@ -0,0 +1,119 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card'; +import { Memory } from '@/types/Memory'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { Calendar, MapPin, Images } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface MemoryCardProps { + memory: Memory; + onClick: () => void; +} + +export function MemoryCard({ memory, onClick }: MemoryCardProps) { + const startDate = new Date(memory.date_range.start); + const endDate = new Date(memory.date_range.end); + + // Format date range + const formatDateRange = () => { + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + }; + const shortOptions: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + }; + + if (memory.type === 'on_this_day') { + return startDate.toLocaleDateString('en-US', options); + } + if (startDate.toDateString() === endDate.toDateString()) { + return startDate.toLocaleDateString('en-US', options); + } + return `${startDate.toLocaleDateString('en-US', shortOptions)} - ${endDate.toLocaleDateString('en-US', options)}`; + }; + + // Get primary thumbnail (first one) + const primaryThumbnail = memory.representative_media[0]?.thumbnailPath; + + return ( + + {/* Image Grid Preview */} +
+ {primaryThumbnail ? ( +
+ {memory.representative_media.slice(0, 6).map((media, idx) => ( +
+ {`Memory +
+ ))} + {memory.representative_media.length < 6 && ( +
+ + + + {Math.max( + 0, + memory.media_count - memory.representative_media.length, + )} + +
+ )} +
+ ) : ( +
+ +
+ )} + + {/* Overlay gradient */} +
+
+ + + {memory.title} + +
+ + {formatDateRange()} +
+ {memory.location && ( +
+ + {memory.location} +
+ )} +
+ + + {memory.media_count}{' '} + {memory.media_count === 1 ? 'photo' : 'photos'} + +
+
+
+ + ); +} diff --git a/frontend/src/components/Memories/MemoryDetailView.tsx b/frontend/src/components/Memories/MemoryDetailView.tsx new file mode 100644 index 000000000..a442c14e1 --- /dev/null +++ b/frontend/src/components/Memories/MemoryDetailView.tsx @@ -0,0 +1,160 @@ +import { useState, useEffect } from 'react'; +import { Memory } from '@/types/Memory'; +import { Button } from '@/components/ui/button'; +import { X, Calendar, MapPin, Images } from 'lucide-react'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { fetchAllImages } from '@/api/api-functions'; +import { Image } from '@/types/Media'; +import { MediaView } from '@/components/Media/MediaView'; +import { useDispatch, useSelector } from 'react-redux'; +import { setImages, setCurrentViewIndex } from '@/features/imageSlice'; +import { + selectCurrentViewIndex, + selectImages, +} from '@/features/imageSelectors'; + +interface MemoryDetailViewProps { + memory: Memory; + onClose: () => void; +} + +export function MemoryDetailView({ memory, onClose }: MemoryDetailViewProps) { + const [images, setLocalImages] = useState([]); + const [loading, setLoading] = useState(true); + const dispatch = useDispatch(); + const currentViewIndex = useSelector(selectCurrentViewIndex); + const reduxImages = useSelector(selectImages); + const showImageViewer = currentViewIndex >= 0; + + // Fetch all images and filter to memory's media IDs + useEffect(() => { + const loadImages = async () => { + try { + setLoading(true); + const response = await fetchAllImages(); + const allImages = (response.data as Image[]) || []; + + // Filter to only images in this memory + const memoryImages = allImages.filter((img) => + memory.media_ids.includes(img.id), + ); + + // Sort by date to maintain chronological order + memoryImages.sort((a, b) => { + const dateA = a.metadata?.date_created || ''; + const dateB = b.metadata?.date_created || ''; + return dateA.localeCompare(dateB); + }); + + setLocalImages(memoryImages); + // Set images in Redux for MediaView + dispatch(setImages(memoryImages)); + } catch (error) { + console.error('Error loading memory images:', error); + } finally { + setLoading(false); + } + }; + + loadImages(); + }, [memory.media_ids]); + + const handleImageClick = (index: number) => { + dispatch(setCurrentViewIndex(index)); + }; + + const formatDateRange = () => { + const startDate = new Date(memory.date_range.start); + const endDate = new Date(memory.date_range.end); + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + }; + + if (startDate.toDateString() === endDate.toDateString()) { + return startDate.toLocaleDateString('en-US', options); + } + return `${startDate.toLocaleDateString('en-US', options)} - ${endDate.toLocaleDateString('en-US', options)}`; + }; + + if (showImageViewer && reduxImages.length > 0) { + return ( + { + dispatch(setCurrentViewIndex(-1)); + }} + images={reduxImages} + type="image" + /> + ); + } + + return ( +
+ {/* Header */} +
+
+
+

{memory.title}

+
+
+ + {formatDateRange()} +
+ {memory.location && ( +
+ + {memory.location} +
+ )} +
+ + + {memory.media_count}{' '} + {memory.media_count === 1 ? 'photo' : 'photos'} + +
+
+
+ +
+
+ + {/* Image Grid */} +
+ {loading ? ( +
+

Loading photos...

+
+ ) : images.length === 0 ? ( +
+

+ No photos found in this memory. +

+
+ ) : ( +
+ {images.map((image, index) => ( +
handleImageClick(index)} + > +
+ {`Memory +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Memories/Memories.tsx b/frontend/src/pages/Memories/Memories.tsx index 92f232b51..a4308a450 100644 --- a/frontend/src/pages/Memories/Memories.tsx +++ b/frontend/src/pages/Memories/Memories.tsx @@ -1,5 +1,92 @@ +import { useState } from 'react'; +import { usePictoQuery } from '@/hooks/useQueryExtension'; +import { fetchAllMemories } from '@/api/api-functions'; +import { Memory } from '@/types/Memory'; +import { MemoryCard } from '@/components/Memories/MemoryCard'; +import { MemoryDetailView } from '@/components/Memories/MemoryDetailView'; +import { EmptyMemoriesState } from '@/components/EmptyStates/EmptyMemoriesState'; +import { useMutationFeedback } from '@/hooks/useMutationFeedback'; +import { Card, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + const Memories = () => { - return <>; + const [selectedMemory, setSelectedMemory] = useState(null); + + const { data, isLoading, isSuccess, isError, error } = usePictoQuery({ + queryKey: ['memories'], + queryFn: () => fetchAllMemories(), + }); + + useMutationFeedback( + { isPending: isLoading, isSuccess, isError, error }, + { + loadingMessage: 'Loading memories', + showSuccess: false, + errorTitle: 'Error', + errorMessage: 'Failed to load memories. Please try again later.', + }, + ); + + const memories = (data?.data as Memory[]) || []; + + // If memory detail view is open, show that + if (selectedMemory) { + return ( + setSelectedMemory(null)} + /> + ); + } + + // Loading state + if (isLoading) { + return ( +
+

Memories

+
+ {[1, 2, 3, 4, 5, 6].map((i) => ( + + + +
+ + +
+
+
+ ))} +
+
+ ); + } + + // Empty state + if (memories.length === 0) { + return ; + } + + // Main memories grid + return ( +
+
+

Memories

+

+ Auto-generated photo memories based on time and location +

+
+ +
+ {memories.map((memory) => ( + setSelectedMemory(memory)} + /> + ))} +
+
+ ); }; export default Memories; diff --git a/frontend/src/routes/AppRoutes.tsx b/frontend/src/routes/AppRoutes.tsx index 22153edbb..7df16fcd1 100644 --- a/frontend/src/routes/AppRoutes.tsx +++ b/frontend/src/routes/AppRoutes.tsx @@ -9,6 +9,7 @@ import { MyFav } from '@/pages/Home/MyFav'; import { AITagging } from '@/pages/AITagging/AITagging'; import { PersonImages } from '@/pages/PersonImages/PersonImages'; import { ComingSoon } from '@/pages/ComingSoon/ComingSoon'; +import Memories from '@/pages/Memories/Memories'; export const AppRoutes: React.FC = () => { return ( @@ -21,7 +22,7 @@ export const AppRoutes: React.FC = () => { } /> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/types/Memory.ts b/frontend/src/types/Memory.ts new file mode 100644 index 000000000..b67f6744b --- /dev/null +++ b/frontend/src/types/Memory.ts @@ -0,0 +1,20 @@ +export interface RepresentativeMedia { + id: string; + thumbnailPath: string; +} + +export interface DateRange { + start: string; + end: string; +} + +export interface Memory { + id: string; + title: string; + type: string; // "on_this_day", "trip", "date_cluster", etc. + date_range: DateRange; + location: string | null; + media_count: number; + representative_media: RepresentativeMedia[]; + media_ids: string[]; +}