feat: timeline and folder import

This commit is contained in:
2026-04-07 00:42:22 +02:00
parent 6d1b227fb9
commit 78e12e8309
20 changed files with 1036 additions and 75 deletions

View File

@@ -4,14 +4,16 @@ import clsx from 'clsx'
interface Photo {
id: string
path: string
filepath: string
filename: string
width: number
height: number
date_taken: string | null
width: number | null
height: number | null
taken_at: string | null
rating: number
flag: string | null
hash: string
is_picked: boolean
is_rejected: boolean
file_hash: string
media_type: string
}
interface PhotoThumbnailProps {
@@ -28,8 +30,8 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbn
// 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
// Calculate aspect ratio for proper sizing (default to 1:1 if dimensions unknown)
const aspectRatio = (photo.height && photo.width) ? photo.height / photo.width : 1
const displayHeight = size * Math.min(aspectRatio, 1.5) // Cap height at 1.5x width
const handleImageLoad = () => {
@@ -49,7 +51,7 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbn
return (
<div
className={clsx(
'relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
'group 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'
@@ -59,6 +61,7 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbn
height: displayHeight,
}}
onClick={onClick}
title="Click to select • Shift+Click for range • Ctrl+Click to add"
>
{/* Thumbnail Image */}
{!imageError ? (
@@ -102,22 +105,20 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbn
)}
{/* 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>
)}
<div className="absolute bottom-1 right-1">
{photo.is_picked && (
<Check className="h-4 w-4 text-pick" />
)}
{photo.is_rejected && (
<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)) && (
{(photo.filepath.toLowerCase().match(/\.(raw|arw|cr2|cr3|nef|orf|rw2|dng)$/i) ||
photo.filepath.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'}
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
</div>
)}
</div>