feat: structure 2

This commit is contained in:
2026-04-07 00:15:00 +02:00
parent 46a0d7aba8
commit 6d1b227fb9
15 changed files with 7433 additions and 94 deletions

View File

@@ -0,0 +1,258 @@
import { useRef, useEffect, useMemo, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { usePhotoStore } from '../../store/photoStore'
import { PhotoThumbnail } from './PhotoThumbnail'
import { useQuery } from '@tanstack/react-query'
import axios from 'axios'
interface Photo {
id: string
path: string
filename: string
width: number
height: number
date_taken: string | null
rating: number
flag: string | null
hash: string
}
export function Timeline() {
const parentRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
const {
selectedPhotos,
lastSelectedIndex,
rangeStartIndex,
selectPhoto,
togglePhotoSelection,
clearSelection,
} = usePhotoStore()
// Helper function for range selection
const selectRange = (endIndex: number) => {
const startIndex = rangeStartIndex ?? lastSelectedIndex ?? 0
const minIndex = Math.min(startIndex, endIndex)
const maxIndex = Math.max(startIndex, endIndex)
// Select all photos in the range
for (let i = minIndex; i <= maxIndex; i++) {
if (i < photos.length && !selectedPhotos.includes(photos[i].id)) {
togglePhotoSelection(photos[i].id, i)
}
}
}
// Thumbnail size configuration
const thumbnailSize = 200 // Base size for thumbnails
const gap = 4
const padding = 16
// Calculate number of columns based on container width
const columns = useMemo(() => {
if (containerWidth === 0) return 4
return Math.floor((containerWidth - padding * 2) / (thumbnailSize + gap))
}, [containerWidth, thumbnailSize, gap, padding])
// Fetch photos from backend
const { data: photos = [], isLoading } = useQuery({
queryKey: ['photos'],
queryFn: async () => {
const response = await axios.get<Photo[]>('http://localhost:8001/api/v1/photos', {
params: {
limit: 1000,
offset: 0,
},
})
return response.data
},
staleTime: 30000,
})
// Group photos into rows for grid layout
const rows = useMemo(() => {
const result: Photo[][] = []
for (let i = 0; i < photos.length; i += columns) {
result.push(photos.slice(i, i + columns))
}
return result
}, [photos, columns])
// Virtual scrolling setup
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => thumbnailSize + gap,
overscan: 5,
})
// Measure container width on mount and resize
useEffect(() => {
const measureWidth = () => {
if (parentRef.current) {
setContainerWidth(parentRef.current.clientWidth)
}
}
measureWidth()
window.addEventListener('resize', measureWidth)
return () => window.removeEventListener('resize', measureWidth)
}, [])
// Handle keyboard shortcuts for photo navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (photos.length === 0) return
const currentIndex = lastSelectedIndex ?? -1
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
if (currentIndex > columns - 1) {
const newIndex = currentIndex - columns
if (e.shiftKey) {
selectRange(newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
}
break
case 'ArrowDown':
e.preventDefault()
if (currentIndex < photos.length - columns) {
const newIndex = Math.min(currentIndex + columns, photos.length - 1)
if (e.shiftKey) {
selectRange(newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
}
break
case 'ArrowLeft':
e.preventDefault()
if (currentIndex > 0) {
const newIndex = currentIndex - 1
if (e.shiftKey) {
selectRange(newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
}
break
case 'ArrowRight':
e.preventDefault()
if (currentIndex < photos.length - 1) {
const newIndex = currentIndex + 1
if (e.shiftKey) {
selectRange(newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
}
break
case 'a':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
// Select all
photos.forEach((photo, index) => {
if (!selectedPhotos.includes(photo.id)) {
togglePhotoSelection(photo.id, index)
}
})
}
break
case 'Escape':
e.preventDefault()
clearSelection()
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [photos, selectedPhotos, lastSelectedIndex, columns, selectPhoto, togglePhotoSelection, selectRange, clearSelection])
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-text-muted">Loading photos...</div>
</div>
)
}
if (photos.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-text-muted">No photos found. Add a source folder to get started.</div>
</div>
)
}
return (
<div
ref={parentRef}
className="h-full overflow-auto bg-bg"
style={{ padding: `${padding}px` }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div
className="flex"
style={{ gap: `${gap}px` }}
>
{row.map((photo, colIndex) => {
const globalIndex = virtualRow.index * columns + colIndex
return (
<PhotoThumbnail
key={photo.id}
photo={photo}
size={thumbnailSize}
isSelected={selectedPhotos.includes(photo.id)}
onClick={(e) => {
if (e.shiftKey && lastSelectedIndex !== null) {
selectRange(globalIndex)
} else if (e.ctrlKey || e.metaKey) {
togglePhotoSelection(photo.id, globalIndex)
} else {
selectPhoto(photo.id, globalIndex)
}
}}
/>
)
})}
</div>
</div>
)
})}
</div>
</div>
)
}