diff --git a/backend/app/routes/memories.py b/backend/app/routes/memories.py new file mode 100644 index 000000000..249afb73e --- /dev/null +++ b/backend/app/routes/memories.py @@ -0,0 +1,193 @@ +from fastapi import APIRouter, HTTPException +from typing import List, Dict, Any +from datetime import datetime +import math + +from app.database.images import db_get_all_images + +router = APIRouter() + +# ------------------------------------------------- +# Helpers +# ------------------------------------------------- + + +def haversine_distance(lat1, lon1, lat2, lon2) -> float: + R = 6371 + lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2]) + dlat = lat2 - lat1 + dlon = lon2 - lon1 + a = ( + math.sin(dlat / 2) ** 2 + + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 + ) + return 2 * R * math.asin(math.sqrt(a)) + + +def decide_memory_type(mem: Dict[str, Any]) -> str: + now = datetime.now() + date = mem["anchor_date"] + lat = mem["lat"] + lon = mem["lon"] + + if date.month == now.month and date.day == now.day and date.year != now.year: + return "on_this_day" + + if lat is not None and lon is not None: + return "trip" + + return "date_range" + + +def generate_title(mem: Dict[str, Any]) -> str: + mtype = decide_memory_type(mem) + date = mem["anchor_date"] + now = datetime.now() + + if mtype == "on_this_day": + years = now.year - date.year + return ( + "On this day last year" + if years == 1 + else f"On this day · {years} years ago" + ) + + if mtype == "trip": + return f"Trip · {date.strftime('%B %Y')}" + + return f"Memories from {date.strftime('%B %Y')}" + + +# ------------------------------------------------- +# Core Logic +# ------------------------------------------------- + + +def group_images(images: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + memories: List[Dict[str, Any]] = [] + + for img in images: + metadata = img.get("metadata", {}) + date_str = metadata.get("date_created") + lat = metadata.get("latitude") + lon = metadata.get("longitude") + + if not date_str: + continue + + try: + date = datetime.fromisoformat(date_str.replace("Z", "+00:00")) + except Exception: + continue + + matched = None + + for mem in memories: + day_diff = abs((date - mem["anchor_date"]).days) + + # 1️⃣ Same-day / near-day memory + if day_diff <= 1: + matched = mem + break + + # 2️⃣ Trip memory (GPS + time proximity) + if ( + day_diff <= 3 + and lat is not None + and lon is not None + and mem["lat"] is not None + and mem["lon"] is not None + and haversine_distance(lat, lon, mem["lat"], mem["lon"]) <= 10 + ): + matched = mem + break + + if not matched: + matched = { + "memory_id": f"memory_{len(memories)}", + "anchor_date": date, + "date_range_start": date, + "date_range_end": date, + "lat": lat, + "lon": lon, + "images": [], + } + memories.append(matched) + + matched["images"].append(img) + matched["date_range_start"] = min(matched["date_range_start"], date) + matched["date_range_end"] = max(matched["date_range_end"], date) + + # ------------------------------------------------- + # Final formatting (IMPORTANT) + # ------------------------------------------------- + + result: List[Dict[str, Any]] = [] + + for mem in memories: + # ❗ Google Photos rule: at least 2 images + if len(mem["images"]) < 2: + continue + + images_sorted = sorted( + mem["images"], key=lambda i: i.get("metadata", {}).get("date_created") or "" + ) + + highlights = images_sorted[:5] + cover = highlights[0] + + result.append( + { + "id": mem["memory_id"], + "title": generate_title(mem), + "memory_type": decide_memory_type(mem), + "date_range_start": mem["date_range_start"].isoformat(), + "date_range_end": mem["date_range_end"].isoformat(), + "image_count": len(mem["images"]), + "representative_image": { + "id": cover["id"], + "path": cover["path"], + "thumbnailPath": cover["thumbnailPath"], + "metadata": cover["metadata"], + }, + "images": mem["images"], + } + ) + + result.sort(key=lambda m: m["date_range_start"], reverse=True) + return result + + +# ------------------------------------------------- +# API +# ------------------------------------------------- + + +@router.get("/") +def get_memories(): + try: + images = db_get_all_images() + return { + "success": True, + "data": group_images(images), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{memory_id}/images") +def get_memory_images(memory_id: str): + try: + images = db_get_all_images() + grouped_memories = group_images(images) + + for mem in grouped_memories: + if mem["id"] == memory_id: + return { + "success": True, + "data": mem["images"], + } + + raise HTTPException(status_code=404, detail="Memory not found") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) 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/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index 44eb908b1..010f94414 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -1117,9 +1117,14 @@ "in": "query", "required": false, "schema": { - "$ref": "#/components/schemas/InputType", + "allOf": [ + { + "$ref": "#/components/schemas/InputType" + } + ], "description": "Choose input type: 'path' or 'base64'", - "default": "path" + "default": "path", + "title": "Input Type" }, "description": "Choose input type: 'path' or 'base64'" } @@ -1299,6 +1304,65 @@ } } } + }, + "/memories/": { + "get": { + "tags": [ + "Memories" + ], + "summary": "Get Memories", + "operationId": "get_memories_memories__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/memories/{memory_id}/images": { + "get": { + "tags": [ + "Memories" + ], + "summary": "Get Memory Images", + "operationId": "get_memory_images_memories__memory_id__images_get", + "parameters": [ + { + "name": "memory_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Memory Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { @@ -2199,7 +2263,6 @@ "metadata": { "anyOf": [ { - "additionalProperties": true, "type": "object" }, { 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..83d5d1a18 --- /dev/null +++ b/frontend/src/api/api-functions/memories.ts @@ -0,0 +1,17 @@ +import { apiClient } from '../axiosConfig'; +import { + MemoriesApiResponse, + MemoryImagesApiResponse, +} from '@/types/memories'; + +export const fetchAllMemories = async (): Promise => { + const response = await apiClient.get('/memories/'); + return response.data; +}; + +export const fetchMemoryImages = async ( + memoryId: string +): Promise => { + const response = await apiClient.get(`/memories/${memoryId}/images`); + return response.data; +}; diff --git a/frontend/src/api/api-functions/togglefav.ts b/frontend/src/api/api-functions/togglefav.ts index 5a1efe0c4..87b9ae456 100644 --- a/frontend/src/api/api-functions/togglefav.ts +++ b/frontend/src/api/api-functions/togglefav.ts @@ -2,10 +2,10 @@ import { imagesEndpoints } from '../apiEndpoints'; import { apiClient } from '../axiosConfig'; import { APIResponse } from '@/types/API'; -export const togglefav = async (image_id: string): Promise => { +export const togglefav = async (image_id: number): Promise => { const response = await apiClient.post( imagesEndpoints.setFavourite, - { image_id }, + { image_id: image_id.toString() }, ); return response.data; }; diff --git a/frontend/src/api/apiEndpoints.ts b/frontend/src/api/apiEndpoints.ts index 69a7e570d..dea083aa1 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/', + getMemoryImages: (memoryId: string) => `/memories/${memoryId}/images`, +}; \ No newline at end of file diff --git a/frontend/src/components/Media/ChronologicalGallery.tsx b/frontend/src/components/Media/ChronologicalGallery.tsx index f033e35a0..4dea6dba0 100644 --- a/frontend/src/components/Media/ChronologicalGallery.tsx +++ b/frontend/src/components/Media/ChronologicalGallery.tsx @@ -59,7 +59,7 @@ export const ChronologicalGallery = ({ }, [sortedGrouped]); const imageIndexMap = useMemo(() => { - const map = new Map(); + const map = new Map(); chronologicallySortedImages.forEach((img, idx) => { map.set(img.id, idx); }); diff --git a/frontend/src/components/Media/MediaThumbnails.tsx b/frontend/src/components/Media/MediaThumbnails.tsx index b92e646ef..12ff18057 100644 --- a/frontend/src/components/Media/MediaThumbnails.tsx +++ b/frontend/src/components/Media/MediaThumbnails.tsx @@ -3,7 +3,7 @@ import { convertFileSrc } from '@tauri-apps/api/core'; interface MediaThumbnailsProps { images: Array<{ - id: string; + id: number; path: string; thumbnailPath: string; }>; diff --git a/frontend/src/hooks/useToggleFav.ts b/frontend/src/hooks/useToggleFav.ts index 8ce0bb94d..e37cb8ef5 100644 --- a/frontend/src/hooks/useToggleFav.ts +++ b/frontend/src/hooks/useToggleFav.ts @@ -4,7 +4,7 @@ import { togglefav } from '@/api/api-functions/togglefav'; export const useToggleFav = () => { const toggleFavouriteMutation = usePictoMutation({ - mutationFn: async (image_id: string) => togglefav(image_id), + mutationFn: async (image_id: number) => togglefav(image_id), autoInvalidateTags: ['images'], }); useMutationFeedback(toggleFavouriteMutation, { @@ -12,7 +12,7 @@ export const useToggleFav = () => { showSuccess: false, }); return { - toggleFavourite: (id: any) => toggleFavouriteMutation.mutate(id), + toggleFavourite: (id: number) => toggleFavouriteMutation.mutate(id), toggleFavouritePending: toggleFavouriteMutation.isPending, }; }; diff --git a/frontend/src/pages/Memories/Memories.tsx b/frontend/src/pages/Memories/Memories.tsx index 92f232b51..1ba5f3337 100644 --- a/frontend/src/pages/Memories/Memories.tsx +++ b/frontend/src/pages/Memories/Memories.tsx @@ -1,5 +1,148 @@ +import { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Memory, Image } from '@/types/Media'; +import { usePictoQuery } from '@/hooks/useQueryExtension'; +import { fetchAllMemories } from '@/api/api-functions'; +import { useMutationFeedback } from '@/hooks/useMutationFeedback'; +import { setCurrentViewIndex, setImages } from '@/features/imageSlice'; +import { MediaView } from '@/components/Media/MediaView'; +import { selectIsImageViewOpen } from '@/features/imageSelectors'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { Calendar, MapPin, ImageIcon, Sparkles } from 'lucide-react'; +import { formatDateRange } from '@/utils/memoryUtils'; + const Memories = () => { - return <>; + const dispatch = useDispatch(); + const [selectedMemoryImages, setSelectedMemoryImages] = useState([]); + const isImageViewOpen = useSelector(selectIsImageViewOpen); + + 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: Memory[] = data?.data || []; + + const handleMemoryClick = (memory: Memory) => { + setSelectedMemoryImages(memory.images); + dispatch(setImages(memory.images)); + dispatch(setCurrentViewIndex(0)); + }; + + const getMemoryIcon = (type: Memory['memory_type']) => { + if (type === 'on_this_day') return ; + if (type === 'trip') return ; + return ; + }; + + const getMemoryBadge = (type: Memory['memory_type']) => { + if (type === 'on_this_day') { + return ( + + On This Day + + ); + } + if (type === 'trip') { + return ( + + Trip + + ); + } + return Memory; + }; + + if (!isLoading && memories.length === 0) { + return ( +
+
+ +
+

No Memories Yet

+

+ Memories are generated automatically from your photos based on time and location. +

+
+ ); + } + + return ( +
+
+

Memories

+

+ Relive moments from your past +

+
+ +
+ {memories.map((memory) => ( + handleMemoryClick(memory)} + > +
+ {memory.representative_image ? ( + {memory.title} + ) : ( +
+ +
+ )} +
+ {getMemoryBadge(memory.memory_type)} +
+
+ + +
+

{memory.title}

+ {getMemoryIcon(memory.memory_type)} +
+ +
+ + {formatDateRange( + memory.date_range_start, + memory.date_range_end, + )} +
+ +
+ + {memory.image_count} photos +
+
+
+ ))} +
+ + {isImageViewOpen && ( + + )} +
+ ); }; export default Memories; diff --git a/frontend/src/pages/Memories/MemoryDetail.tsx b/frontend/src/pages/Memories/MemoryDetail.tsx new file mode 100644 index 000000000..3f27582a6 --- /dev/null +++ b/frontend/src/pages/Memories/MemoryDetail.tsx @@ -0,0 +1,91 @@ +import { useNavigate, useLocation } from 'react-router'; +import { ArrowLeft, Calendar, MapPin } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const MemoryDetail = () => { + const navigate = useNavigate(); + const location = useLocation(); + const memory = location.state?.memory; + + const handleBack = () => { + navigate('/memories'); + }; + + const openInGallery = () => { + navigate('/gallery', { + state: { + startDate: memory.date_range_start, + endDate: memory.date_range_end, + latitude: memory.latitude, + longitude: memory.longitude, + }, + }); + }; + + if (!memory) { + navigate('/memories'); + return null; + } + + const formatDateRange = (start: string, end: string) => { + const s = new Date(start); + const e = new Date(end); + + if (s.toDateString() === e.toDateString()) { + return s.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); + } + + return `${s.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + })} – ${e.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })}`; + }; + + return ( +
+ + +

{memory.title}

+ +
+
+ + {formatDateRange( + memory.date_range_start, + memory.date_range_end, + )} +
+ + {memory.location_name && ( +
+ + {memory.location_name} +
+ )} + + {memory.image_count} photos +
+ + +
+ ); +}; + +export default MemoryDetail; diff --git a/frontend/src/routes/AppRoutes.tsx b/frontend/src/routes/AppRoutes.tsx index 22153edbb..20d969be1 100644 --- a/frontend/src/routes/AppRoutes.tsx +++ b/frontend/src/routes/AppRoutes.tsx @@ -9,6 +9,9 @@ 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'; +import MemoryDetail from '@/pages/Memories/MemoryDetail'; + export const AppRoutes: React.FC = () => { return ( @@ -21,7 +24,8 @@ export const AppRoutes: React.FC = () => { } /> } /> } /> - } /> + } /> + } /> } /> diff --git a/frontend/src/types/Media.ts b/frontend/src/types/Media.ts index d7e0712fc..2240cf7e6 100644 --- a/frontend/src/types/Media.ts +++ b/frontend/src/types/Media.ts @@ -11,7 +11,7 @@ export interface ImageMetadata { } export interface Image { - id: string; + id: number; path: string; thumbnailPath: string; folder_id: string; @@ -48,6 +48,26 @@ export interface PaginationControlsProps { totalPages: number; onPageChange: (page: number) => void; } +export interface MemoryImage { + id: number; + path: string; + thumbnailPath: string; + metadata: ImageMetadata; +} + +export interface Memory { + id: string; + title: string; + memory_type: 'on_this_day' | 'trip' | 'date_range'; + date_range_start: string; + date_range_end: string; + image_count: number; + representative_image?: MemoryImage; + images: Image[]; + latitude?: number; + longitude?: number; + location_name?: string; +} export interface Cluster { cluster_id: string; diff --git a/frontend/src/types/memories.ts b/frontend/src/types/memories.ts new file mode 100644 index 000000000..df7249122 --- /dev/null +++ b/frontend/src/types/memories.ts @@ -0,0 +1,11 @@ +import { Memory, Image } from '@/types/Media'; + +export interface MemoriesApiResponse { + success: boolean; + data: Memory[]; +} + +export interface MemoryImagesApiResponse { + success: boolean; + data: Image[]; +} diff --git a/frontend/src/utils/memoryUtils.ts b/frontend/src/utils/memoryUtils.ts new file mode 100644 index 000000000..be584fb4d --- /dev/null +++ b/frontend/src/utils/memoryUtils.ts @@ -0,0 +1,21 @@ +export const formatDateRange = (start: string, end: string): string => { + const s = new Date(start); + const e = new Date(end); + + if (s.toDateString() === e.toDateString()) { + return s.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); + } + + return `${s.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + })} – ${e.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })}`; +}; \ No newline at end of file