feat: structure 2
This commit is contained in:
125
frontend/src/components/timeline/PhotoThumbnail.tsx
Normal file
125
frontend/src/components/timeline/PhotoThumbnail.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Star, Check, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface Photo {
|
||||
id: string
|
||||
path: string
|
||||
filename: string
|
||||
width: number
|
||||
height: number
|
||||
date_taken: string | null
|
||||
rating: number
|
||||
flag: string | null
|
||||
hash: string
|
||||
}
|
||||
|
||||
interface PhotoThumbnailProps {
|
||||
photo: Photo
|
||||
size: number
|
||||
isSelected: boolean
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbnailProps) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
|
||||
// Generate thumbnail URL - assuming backend serves thumbnails at /api/photos/{id}/thumbnail
|
||||
const thumbnailUrl = `http://localhost:8001/api/v1/photos/${photo.id}/thumb/medium`
|
||||
|
||||
// Calculate aspect ratio for proper sizing
|
||||
const aspectRatio = photo.height / photo.width
|
||||
const displayHeight = size * Math.min(aspectRatio, 1.5) // Cap height at 1.5x width
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setImageLoaded(true)
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
setImageError(true)
|
||||
}
|
||||
|
||||
// Reset state when photo changes
|
||||
useEffect(() => {
|
||||
setImageError(false)
|
||||
setImageLoaded(false)
|
||||
}, [photo.id])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
|
||||
'hover:ring-2 hover:ring-primary/50',
|
||||
isSelected && 'ring-2 ring-primary shadow-lg',
|
||||
!imageLoaded && 'bg-surface animate-pulse'
|
||||
)}
|
||||
style={{
|
||||
width: size,
|
||||
height: displayHeight,
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Thumbnail Image */}
|
||||
{!imageError ? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={photo.filename}
|
||||
className={clsx(
|
||||
'h-full w-full object-cover transition-opacity duration-200',
|
||||
imageLoaded ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-surface text-text-muted">
|
||||
<div className="text-center text-xs">
|
||||
<div>Unable to load</div>
|
||||
<div className="mt-1 font-mono text-[10px]">{photo.filename}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection Indicator */}
|
||||
{isSelected && (
|
||||
<div className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white">
|
||||
<Check className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rating Stars */}
|
||||
{photo.rating > 0 && (
|
||||
<div className="absolute bottom-1 left-1 flex gap-0.5">
|
||||
{Array.from({ length: photo.rating }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className="h-3 w-3 fill-star text-star"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flag Indicators */}
|
||||
{photo.flag && (
|
||||
<div className="absolute bottom-1 right-1">
|
||||
{photo.flag === 'pick' && (
|
||||
<Check className="h-4 w-4 text-pick" />
|
||||
)}
|
||||
{photo.flag === 'reject' && (
|
||||
<X className="h-4 w-4 text-reject" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Type Badge for RAW/Video */}
|
||||
{(photo.path.toLowerCase().match(/\.(raw|arw|cr2|cr3|nef|orf|rw2|dng)$/i) ||
|
||||
photo.path.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i)) && (
|
||||
<div className="absolute right-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[10px] font-medium text-white">
|
||||
{photo.path.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
258
frontend/src/components/timeline/Timeline.tsx
Normal file
258
frontend/src/components/timeline/Timeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user