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:
@@ -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 — {data?.date}
|
role="grid"
|
||||||
|
aria-label="Memories"
|
||||||
|
>
|
||||||
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-text">
|
||||||
|
On this day · {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} · {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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user