- 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>
252 lines
8.7 KiB
TypeScript
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>
|
|
)
|
|
} |