feat: "On this day" memories — photos from previous years
Add a Memories view that surfaces photos taken on the current date in previous years (like Google Photos / Immich). Only uses EXIF-sourced dates to avoid false matches from filesystem timestamps. - Backend: GET /api/v1/photos/memories returns groups by year, up to 12 photos each, filtered to non-discarded/non-hidden EXIF dates - Frontend: MemoriesView with year-grouped thumbnail grid - Sidebar: new "Memories" nav item with clock icon Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -352,6 +352,74 @@ async def list_photos_with_gps(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/memories")
|
||||||
|
async def get_memories(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""'On this day' — photos taken on this date in previous years.
|
||||||
|
|
||||||
|
Returns groups keyed by year, each with up to 12 photos. Only
|
||||||
|
considers non-discarded, non-hidden photos with an EXIF-sourced
|
||||||
|
taken_at (no filesystem-guessed dates to avoid false matches).
|
||||||
|
"""
|
||||||
|
from sqlalchemy import extract
|
||||||
|
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
Photo.id,
|
||||||
|
Photo.filename,
|
||||||
|
Photo.taken_at,
|
||||||
|
Photo.thumb_small,
|
||||||
|
Photo.thumb_medium,
|
||||||
|
Photo.media_type,
|
||||||
|
Photo.width,
|
||||||
|
Photo.height,
|
||||||
|
Photo.rating,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Photo.user_id == current_user.id,
|
||||||
|
Photo.is_discarded.is_(False),
|
||||||
|
Photo.is_hidden.is_(False),
|
||||||
|
Photo.taken_at.is_not(None),
|
||||||
|
Photo.taken_at_source == "exif",
|
||||||
|
extract("month", Photo.taken_at) == today.month,
|
||||||
|
extract("day", Photo.taken_at) == today.day,
|
||||||
|
extract("year", Photo.taken_at) < today.year,
|
||||||
|
)
|
||||||
|
.order_by(Photo.taken_at.desc())
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
# Group by year
|
||||||
|
years: dict[int, list] = {}
|
||||||
|
for row in rows:
|
||||||
|
year = row.taken_at.year
|
||||||
|
group = years.setdefault(year, [])
|
||||||
|
if len(group) >= 12:
|
||||||
|
continue
|
||||||
|
group.append({
|
||||||
|
"id": row.id,
|
||||||
|
"filename": row.filename,
|
||||||
|
"taken_at": row.taken_at.isoformat(),
|
||||||
|
"thumb_small": row.thumb_small,
|
||||||
|
"thumb_medium": row.thumb_medium,
|
||||||
|
"media_type": row.media_type,
|
||||||
|
"width": row.width,
|
||||||
|
"height": row.height,
|
||||||
|
"rating": row.rating,
|
||||||
|
})
|
||||||
|
|
||||||
|
memories = [
|
||||||
|
{"year": year, "years_ago": today.year - year, "photos": photos}
|
||||||
|
for year, photos in sorted(years.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
return {"date": today.isoformat(), "memories": memories}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{photo_id}")
|
@router.get("/{photo_id}")
|
||||||
async def get_photo(
|
async def get_photo(
|
||||||
photo_id: str,
|
photo_id: str,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Timeline } from './components/timeline/Timeline'
|
import { Timeline } from './components/timeline/Timeline'
|
||||||
import { DuplicatesView } from './components/duplicates/DuplicatesView'
|
import { DuplicatesView } from './components/duplicates/DuplicatesView'
|
||||||
import { MapView } from './components/map/MapView'
|
import { MapView } from './components/map/MapView'
|
||||||
|
import { MemoriesView } from './components/memories/MemoriesView'
|
||||||
import { PeopleView } from './components/people/PeopleView'
|
import { PeopleView } from './components/people/PeopleView'
|
||||||
import { TagsView } from './components/tags/TagsView'
|
import { TagsView } from './components/tags/TagsView'
|
||||||
import { ColorsView } from './components/colors/ColorsView'
|
import { ColorsView } from './components/colors/ColorsView'
|
||||||
@@ -87,6 +88,8 @@ function MainApp() {
|
|||||||
<SettingsPage />
|
<SettingsPage />
|
||||||
) : currentSection === 'map' ? (
|
) : currentSection === 'map' ? (
|
||||||
<MapView />
|
<MapView />
|
||||||
|
) : currentSection === 'memories' ? (
|
||||||
|
<MemoriesView />
|
||||||
) : currentSection === 'duplicates' ? (
|
) : currentSection === 'duplicates' ? (
|
||||||
<DuplicatesView />
|
<DuplicatesView />
|
||||||
) : currentSection === 'people' ? (
|
) : currentSection === 'people' ? (
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
LogOut,
|
LogOut,
|
||||||
Shield,
|
Shield,
|
||||||
|
Clock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api'
|
import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api'
|
||||||
@@ -407,6 +408,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
|
|||||||
{ id: 'people', label: 'People', icon: <Users className="h-4 w-4" />, count: peopleTotalCount },
|
{ id: 'people', label: 'People', icon: <Users className="h-4 w-4" />, count: peopleTotalCount },
|
||||||
{ id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" />, count: stats?.colored ?? 0 },
|
{ id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" />, count: stats?.colored ?? 0 },
|
||||||
{ id: 'map', label: 'Map', icon: <MapPin className="h-4 w-4" />, count: stats?.with_gps ?? 0 },
|
{ id: 'map', label: 'Map', icon: <MapPin className="h-4 w-4" />, count: stats?.with_gps ?? 0 },
|
||||||
|
{ id: 'memories', label: 'Memories', icon: <Clock className="h-4 w-4" /> },
|
||||||
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },
|
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },
|
||||||
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
|
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
|
||||||
],
|
],
|
||||||
|
|||||||
70
frontend/src/components/memories/MemoriesView.tsx
Normal file
70
frontend/src/components/memories/MemoriesView.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { photos } from '../../services/api'
|
||||||
|
import type { MemoryGroup } from '../../services/api'
|
||||||
|
|
||||||
|
export function MemoriesView() {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['memories'],
|
||||||
|
queryFn: () => photos.memories(),
|
||||||
|
staleTime: 60_000 * 30, // 30 min — date doesn't change often
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64 text-neutral-500">
|
||||||
|
Loading memories...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const memories = data?.memories ?? []
|
||||||
|
|
||||||
|
if (memories.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 text-neutral-500 gap-2">
|
||||||
|
<p className="text-lg font-medium">No memories for today</p>
|
||||||
|
<p className="text-sm">Photos taken on this date in previous years will appear here.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-8 max-w-5xl mx-auto">
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-200">
|
||||||
|
On This Day — {data?.date}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{memories.map((group: MemoryGroup) => (
|
||||||
|
<section key={group.year} className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-400 uppercase tracking-wide">
|
||||||
|
{group.year} · {group.years_ago} year{group.years_ago !== 1 ? 's' : ''} ago
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
|
||||||
|
{group.photos.map((photo) => (
|
||||||
|
<div
|
||||||
|
key={photo.id}
|
||||||
|
className="aspect-square rounded-lg overflow-hidden bg-neutral-800 relative group"
|
||||||
|
>
|
||||||
|
{photo.thumb_small ? (
|
||||||
|
<img
|
||||||
|
src={`/api/v1/photos/${photo.id}/thumb/small`}
|
||||||
|
alt={photo.filename}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-neutral-600 text-xs">
|
||||||
|
No thumb
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<p className="text-xs text-white truncate">{photo.filename}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -328,6 +328,12 @@ export const photos = {
|
|||||||
getProxyUrl: (photoId: string) => {
|
getProxyUrl: (photoId: string) => {
|
||||||
return `${API_BASE_URL}/photos/${photoId}/proxy`
|
return `${API_BASE_URL}/photos/${photoId}/proxy`
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** "On this day" memories — photos taken on this date in previous years. */
|
||||||
|
memories: async (): Promise<MemoriesResponse> => {
|
||||||
|
const response = await api.get('/photos/memories')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Library API
|
// Library API
|
||||||
@@ -577,6 +583,31 @@ export interface DuplicateGroupsResponse {
|
|||||||
total_members: number
|
total_members: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Memories ("On this day") ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MemoryPhoto {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
taken_at: string
|
||||||
|
thumb_small: string | null
|
||||||
|
thumb_medium: string | null
|
||||||
|
media_type: string
|
||||||
|
width: number | null
|
||||||
|
height: number | null
|
||||||
|
rating: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryGroup {
|
||||||
|
year: number
|
||||||
|
years_ago: number
|
||||||
|
photos: MemoryPhoto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoriesResponse {
|
||||||
|
date: string
|
||||||
|
memories: MemoryGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface LibraryStats {
|
export interface LibraryStats {
|
||||||
all_photos: number
|
all_photos: number
|
||||||
rated: number
|
rated: number
|
||||||
|
|||||||
Reference in New Issue
Block a user