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,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>
)
}