photos/src/components/DirectoryList.tsx
Michael Mainguy 868ef2eeaa Add photo scanning with EXIF metadata extraction and thumbnail caching
- Implement file scanner with SHA256 hash-based duplicate detection
- Add Sharp-based thumbnail generation with object-contain display
- Create comprehensive photo grid with EXIF metadata overlay
- Add SQLite thumbnail blob caching for improved performance
- Support full image preview with proper aspect ratio preservation
- Include background directory scanning with progress tracking

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 08:35:07 -05:00

252 lines
8.7 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import { IconFolder, IconClock, IconTrash, IconScan } from '@tabler/icons-react'
import { Directory } from '@/types/photo'
import Button from "@/components/Button";
interface DirectoryListProps {
onDirectorySelect?: (directory: Directory) => void
selectedDirectory?: string
refreshTrigger?: number // Add a trigger to force refresh
}
export default function DirectoryList({ onDirectorySelect, selectedDirectory, refreshTrigger }: DirectoryListProps) {
const [directories, setDirectories] = useState<Directory[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [scanningDirectories, setScanningDirectories] = useState<Set<string>>(new Set())
const fetchDirectories = async () => {
try {
setLoading(true)
const response = await fetch('/api/directories')
if (!response.ok) {
throw new Error('Failed to fetch directories')
}
const data = await response.json()
setDirectories(data.directories || [])
setError(null)
} catch (error) {
console.error('Error fetching directories:', error)
setError('Failed to load directories')
} finally {
setLoading(false)
}
}
const deleteDirectory = async (directoryId: string) => {
try {
const response = await fetch(`/api/directories/${directoryId}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete directory')
}
// Refresh the list
fetchDirectories()
} catch (error) {
console.error('Error deleting directory:', error)
}
}
const scanDirectory = async (directory: Directory) => {
const requestStartTime = Date.now()
console.log(`[CLIENT] Starting scan request for directory: ${directory.path}`)
console.log(`[CLIENT] Directory details:`, {
id: directory.id,
name: directory.name,
path: directory.path,
lastScanned: directory.last_scanned,
photoCount: directory.photo_count
})
try {
// Mark directory as scanning
setScanningDirectories(prev => new Set(prev).add(directory.path))
console.log(`[CLIENT] Marked directory as scanning: ${directory.path}`)
console.log(`[CLIENT] Sending POST request to /api/scan`)
const response = await fetch('/api/scan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ directoryPath: directory.path }),
})
const requestDuration = Date.now() - requestStartTime
console.log(`[CLIENT] API response received after ${requestDuration}ms, status: ${response.status}`)
if (!response.ok) {
const errorText = await response.text()
console.error(`[CLIENT] API request failed:`, errorText)
throw new Error(`Failed to start directory scan: ${response.status} ${errorText}`)
}
const result = await response.json()
console.log(`[CLIENT] Directory scan started successfully:`, result)
console.log(`[CLIENT] Background scan is now running for: ${directory.path}`)
// Remove scanning status after a brief delay (scan runs in background)
setTimeout(() => {
console.log(`[CLIENT] Removing scanning status for: ${directory.path}`)
setScanningDirectories(prev => {
const newSet = new Set(prev)
newSet.delete(directory.path)
return newSet
})
// Refresh directory list to show updated last_scanned time
console.log(`[CLIENT] Refreshing directory list to show updated scan time`)
fetchDirectories()
}, 2000)
} catch (error) {
const requestDuration = Date.now() - requestStartTime
console.error(`[CLIENT] Error starting directory scan for ${directory.path} after ${requestDuration}ms:`, error)
// Remove scanning status on error
setScanningDirectories(prev => {
const newSet = new Set(prev)
newSet.delete(directory.path)
return newSet
})
}
}
useEffect(() => {
fetchDirectories()
}, [refreshTrigger])
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
if (loading) {
return (
<div className="p-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
Saved Directories
</h3>
<div className="animate-pulse space-y-2">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div>
</div>
)
}
if (error) {
return (
<div className="p-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
Saved Directories
</h3>
<div className="text-sm text-red-600 dark:text-red-400">
{error}
</div>
</div>
)
}
return (
<div className="p-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
Saved Directories
</h3>
{directories.length === 0 ? (
<div className="text-sm text-gray-500 dark:text-gray-400 italic">
No directories saved yet
</div>
) : (
<div className="space-y-1">
{directories.map((directory) => (
<div
key={directory.id}
className={`group relative rounded-lg p-3 cursor-pointer transition-colors ${
selectedDirectory === directory.path
? 'bg-blue-100 dark:bg-blue-900/30'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
onClick={() => onDirectorySelect?.(directory)}
>
<div className="flex items-start justify-between">
<div className="flex items-start space-x-2 min-w-0 flex-1">
<IconFolder className="h-4 w-4 text-gray-400 dark:text-gray-500 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
{directory.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{directory.path}
</div>
<div className="flex items-center space-x-3 mt-1">
<div className="flex items-center space-x-1 text-xs text-gray-500 dark:text-gray-400">
<IconClock className="h-3 w-3" />
<span>{formatDate(directory.last_scanned)}</span>
</div>
{directory.photo_count > 0 && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{directory.photo_count} photos
</div>
)}
{directory.total_size > 0 && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(directory.total_size)}
</div>
)}
</div>
<div className="flex gap-2 mt-2">
<Button
leftIcon={IconScan}
onClick={(e) => {
e.stopPropagation()
scanDirectory(directory)
}}
disabled={scanningDirectories.has(directory.path)}
>
{scanningDirectories.has(directory.path) ? 'Scanning...' : 'Scan'}
</Button>
</div>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation()
deleteDirectory(directory.id)
}}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-all"
title="Delete directory"
>
<IconTrash className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}