feat(memories): use Timeline's PhotoThumbnail grid

MemoriesView now renders PhotoThumbnail cells wired up to the shared
photoStore so selection, heap membership, preview (double-click /
Enter), badges, drag-to-heap, and search-match highlighting all work
the same way they do in Timeline. Kept the per-year section grouping,
swapped the bespoke img tiles for the shared component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 10:57:29 +02:00
parent 967cf23b82
commit 744a7fa0c3

View File

@@ -1,70 +1,162 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { photos, photos as photosApi } from '../../services/api' import { Loader2 } from 'lucide-react'
import type { MemoryGroup } from '../../services/api' import {
photos,
type MemoriesResponse,
type MemoryPhoto,
} from '../../services/api'
import type { Photo } from '../../types/photo'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore'
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
const CELL_SIZE = 180
const GAP = 4
/**
* "On this day" view. Same visual + interaction grid as Timeline —
* PhotoThumbnail cells wired up to the shared photo store, so selection,
* heap membership, preview (double-click / Enter), and drag-to-heap all
* work identically. Just grouped by year instead of month, and sourced
* from the /memories endpoint.
*/
export function MemoriesView() { export function MemoriesView() {
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery<MemoriesResponse>({
queryKey: ['memories'], queryKey: ['memories'],
queryFn: () => photos.memories(), queryFn: photos.memories,
staleTime: 60_000 * 30, // 30 min — date doesn't change often staleTime: 60_000 * 30,
}) })
const memories = data?.memories ?? []
// Flat ordered sequence of ids across every year section — fed to
// the store so preview arrow nav walks the same order the user sees.
const visibleSequence = useMemo(
() => memories.flatMap((g) => g.photos.map((p) => p.id)),
[memories],
)
const setVisiblePhotoIds = usePhotoStore((s) => s.setVisiblePhotoIds)
useEffect(() => {
setVisiblePhotoIds(visibleSequence)
}, [visibleSequence, setVisiblePhotoIds])
const selectedPhotos = usePhotoStore((s) => s.selectedPhotos)
const selectPhoto = usePhotoStore((s) => s.selectPhoto)
const togglePhotoSelection = usePhotoStore((s) => s.togglePhotoSelection)
const selectRange = usePhotoStore((s) => s.selectRange)
const openPreview = usePhotoStore((s) => s.openPreview)
const searchQuery = useFilterStore((s) => s.q)
const visibleSequenceRef = useRef<string[]>([])
visibleSequenceRef.current = visibleSequence
const handleCellClick = useCallback(
(photo: Photo, e: React.MouseEvent) => {
if (e.shiftKey) selectRange(photo.id)
else if (e.ctrlKey || e.metaKey) togglePhotoSelection(photo.id)
else selectPhoto(photo.id)
},
[selectRange, togglePhotoSelection, selectPhoto],
)
const handleCellDoubleClick = useCallback(
(photo: Photo) => {
openPreview(photo.id, visibleSequenceRef.current)
},
[openPreview],
)
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-64 text-neutral-500"> <div className="flex h-full items-center justify-center text-text-muted">
Loading memories... <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading memories
</div> </div>
) )
} }
const memories = data?.memories ?? []
if (memories.length === 0) { if (memories.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center h-64 text-neutral-500 gap-2"> <div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-text-muted">
<p className="text-lg font-medium">No memories for today</p> <div className="text-sm font-medium text-text">
<p className="text-sm">Photos taken on this date in previous years will appear here.</p> No memories for today
</div>
<p className="max-w-sm text-xs">
Photos taken on this date in previous years will appear here.
</p>
</div> </div>
) )
} }
return ( return (
<div className="p-6 space-y-8 max-w-5xl mx-auto"> <div
<h2 className="text-xl font-semibold text-neutral-200"> className="h-full overflow-auto bg-bg p-4"
On This Day &mdash; {data?.date} role="grid"
aria-label="Memories"
>
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-text">
On this day &middot; {data?.date}
</h2> </h2>
{memories.map((group: MemoryGroup) => ( <div className="space-y-6">
<section key={group.year} className="space-y-3"> {memories.map((group) => (
<h3 className="text-sm font-medium text-neutral-400 uppercase tracking-wide"> <section key={group.year} className="space-y-2">
{group.year} &middot; {group.years_ago} year{group.years_ago !== 1 ? 's' : ''} ago <header className="flex items-baseline gap-2">
</h3> <h3 className="text-sm font-semibold uppercase tracking-wide text-text">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2"> {group.year}
{group.photos.map((photo) => ( </h3>
<div <span className="text-xs text-text-muted">
key={photo.id} {group.years_ago} year{group.years_ago === 1 ? '' : 's'} ago
className="aspect-square rounded-lg overflow-hidden bg-neutral-800 relative group" </span>
> </header>
{photo.thumb_small ? ( <div
<img className="grid"
src={photosApi.getThumbnailUrl(photo.id, 'small')} style={{
alt={photo.filename} gridTemplateColumns: `repeat(auto-fill, minmax(${CELL_SIZE}px, 1fr))`,
className="w-full h-full object-cover" gridAutoRows: `${CELL_SIZE}px`,
loading="lazy" gap: `${GAP}px`,
/> }}
) : ( >
<div className="w-full h-full flex items-center justify-center text-neutral-600 text-xs"> {group.photos.map((m) => (
No thumb <PhotoThumbnail
</div> key={m.id}
)} photo={memoryToPhoto(m)}
<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"> size={CELL_SIZE}
<p className="text-xs text-white truncate">{photo.filename}</p> fill
</div> isSelected={selectedPhotos.includes(m.id)}
</div> searchQuery={searchQuery}
))} onClick={handleCellClick}
</div> onDoubleClick={handleCellDoubleClick}
</section> />
))} ))}
</div>
</section>
))}
</div>
</div> </div>
) )
} }
/** Widen the slim MemoryPhoto payload to the full Photo shape the
* thumbnail expects. Fields the memories endpoint doesn't return
* (is_duplicate, file_hash, ...) get sensible defaults — memories
* never surface discarded/duplicate photos server-side. */
function memoryToPhoto(m: MemoryPhoto): Photo {
return {
id: m.id,
filepath: m.filename,
filename: m.filename,
media_type: m.media_type,
width: m.width,
height: m.height,
taken_at: m.taken_at,
rating: m.rating,
is_discarded: false,
is_duplicate: false,
file_hash: '',
folder_id: null,
added_at: null,
thumb_small: m.thumb_small ?? undefined,
thumb_medium: m.thumb_medium ?? undefined,
}
}