diff --git a/src/app/api/photos/route.ts b/src/app/api/photos/route.ts index 61eacdc..ffdae69 100644 --- a/src/app/api/photos/route.ts +++ b/src/app/api/photos/route.ts @@ -15,11 +15,34 @@ export async function GET(request: NextRequest) { rating: searchParams.get('rating') ? parseInt(searchParams.get('rating')!) : undefined } + // Get photos with pagination const photos = photoService.getPhotos(options) + // Get total count for pagination metadata + const totalCountOptions = { ...options } + delete totalCountOptions.limit + delete totalCountOptions.offset + const totalPhotos = photoService.getPhotos(totalCountOptions) + const totalCount = totalPhotos.length + + // Calculate pagination metadata + const limit = options.limit || 20 + const offset = options.offset || 0 + const hasMore = (offset + photos.length) < totalCount + const currentPage = Math.floor(offset / limit) + 1 + const totalPages = Math.ceil(totalCount / limit) + return NextResponse.json({ photos, - count: photos.length + pagination: { + total: totalCount, + count: photos.length, + limit, + offset, + hasMore, + currentPage, + totalPages + } }) } catch (error) { console.error('Error fetching photos:', error) diff --git a/src/components/DirectoryModal.tsx b/src/components/DirectoryModal.tsx index 6493d43..b42d0aa 100644 --- a/src/components/DirectoryModal.tsx +++ b/src/components/DirectoryModal.tsx @@ -180,72 +180,84 @@ export default function DirectoryModal({ isOpen, onClose, onSave, onDirectoryLis } const modalContent = ( -
+
e.stopPropagation()} > -

- Select Directory to Scan -

- -
- setShowSuggestions(suggestions.length > 0)} - placeholder="/path/to/photos" - className="w-full p-2 pr-10 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white" - autoFocus - /> -
- {isValidating && ( -
- )} - {!isValidating && isValid === true && ( - - )} - {!isValidating && (isValid === false || (directory && isValid === null)) && ( - - )} -
- - {showSuggestions && suggestions.length > 0 && ( -
- {suggestions.map((suggestion, index) => ( -
{ suggestionRefs.current[index] = el }} - onClick={() => handleSuggestionClick(suggestion)} - className={`px-3 py-2 cursor-pointer text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-600 last:border-b-0 ${ - index === selectedSuggestionIndex - ? 'bg-blue-100 dark:bg-blue-900' - : 'hover:bg-gray-100 dark:hover:bg-gray-600' - }`} - > - {suggestion} -
- ))} -
- )} + {/* Header */} +
+

+ Select Directory to Scan +

-
- - + {/* Content Area - Scrollable */} +
+
+ setShowSuggestions(suggestions.length > 0)} + placeholder="/path/to/photos" + className="w-full p-2 pr-10 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + autoFocus + /> +
+ {isValidating && ( +
+ )} + {!isValidating && isValid === true && ( + + )} + {!isValidating && (isValid === false || (directory && isValid === null)) && ( + + )} +
+ + {/* Suggestions - Now with better scrolling */} + {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion, index) => ( +
{ suggestionRefs.current[index] = el }} + onClick={() => handleSuggestionClick(suggestion)} + className={`px-3 py-2 cursor-pointer text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-600 last:border-b-0 ${ + index === selectedSuggestionIndex + ? 'bg-blue-100 dark:bg-blue-900' + : 'hover:bg-gray-100 dark:hover:bg-gray-600' + }`} + > +
+ {suggestion} +
+
+ ))} +
+ )} +
+
+ + {/* Fixed Footer with Buttons */} +
+
+ + +
diff --git a/src/components/PhotoGrid.tsx b/src/components/PhotoGrid.tsx index 3d61b4f..83f30d5 100644 --- a/src/components/PhotoGrid.tsx +++ b/src/components/PhotoGrid.tsx @@ -22,21 +22,36 @@ export default function PhotoGrid({ }: PhotoGridProps) { const [photos, setPhotos] = useState([]) const [loading, setLoading] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) const [error, setError] = useState(null) const [sortBy, setSortBy] = useState('created_at') const [sortOrder, setSortOrder] = useState('DESC') const [searchTerm, setSearchTerm] = useState('') const [selectedPhoto, setSelectedPhoto] = useState(null) + const [pagination, setPagination] = useState({ + total: 0, + hasMore: false, + currentPage: 1, + limit: 20, + offset: 0 + }) - const fetchPhotos = async () => { + const fetchPhotos = async (reset = true) => { try { - setLoading(true) + if (reset) { + setLoading(true) + setPhotos([]) + } else { + setLoadingMore(true) + } setError(null) + const offset = reset ? 0 : pagination.offset const params = new URLSearchParams({ sortBy, sortOrder, - limit: '100', // Load first 100 photos + limit: pagination.limit.toString(), + offset: offset.toString() }) if (directoryPath) { @@ -50,12 +65,42 @@ export default function PhotoGrid({ } const data = await response.json() - setPhotos(data.photos || []) + + if (reset) { + setPhotos(data.photos || []) + } else { + // Prevent duplicates by filtering out photos we already have using functional update + setPhotos(prev => { + const incomingPhotos = data.photos || [] + const newPhotos = incomingPhotos.filter(newPhoto => + !prev.some(existingPhoto => existingPhoto.id === newPhoto.id) + ) + console.log(`[PHOTO GRID] Received ${incomingPhotos.length} photos, ${newPhotos.length} are new, ${incomingPhotos.length - newPhotos.length} were duplicates`) + return [...prev, ...newPhotos] + }) + } + + setPagination({ + total: data.pagination.total, + hasMore: data.pagination.hasMore, + currentPage: data.pagination.currentPage, + limit: data.pagination.limit, + offset: offset + (data.photos || []).length + }) + } catch (error) { console.error('Error fetching photos:', error) setError('Failed to load photos') } finally { setLoading(false) + setLoadingMore(false) + } + } + + const loadMorePhotos = () => { + if (!loadingMore && pagination.hasMore) { + console.log(`[PHOTO GRID] Loading more photos: offset=${pagination.offset}, current photos=${photos.length}`) + fetchPhotos(false) } } @@ -63,6 +108,31 @@ export default function PhotoGrid({ fetchPhotos() }, [directoryPath, sortBy, sortOrder]) + // Infinite scroll effect + useEffect(() => { + let scrollTimeout: NodeJS.Timeout | null = null + + const handleScroll = () => { + if (scrollTimeout) return // Prevent multiple calls + + scrollTimeout = setTimeout(() => { + if ( + window.innerHeight + document.documentElement.scrollTop + 1000 >= + document.documentElement.offsetHeight + ) { + loadMorePhotos() + } + scrollTimeout = null + }, 100) // 100ms throttle + } + + window.addEventListener('scroll', handleScroll) + return () => { + window.removeEventListener('scroll', handleScroll) + if (scrollTimeout) clearTimeout(scrollTimeout) + } + }, [loadingMore, pagination.hasMore]) + // Filter photos based on search term const filteredPhotos = photos.filter(photo => { if (!searchTerm) return true @@ -101,6 +171,8 @@ export default function PhotoGrid({ setSortBy(newSortBy) setSortOrder('DESC') // Default to descending for new field } + // Reset pagination when sorting changes + setPagination(prev => ({ ...prev, currentPage: 1, hasMore: false, offset: 0 })) } // Grid classes based on thumbnail size - made thumbnails larger @@ -159,7 +231,8 @@ export default function PhotoGrid({ Photos {directoryPath && `in ${directoryPath.split('/').pop()}`} - {filteredPhotos.length} photo{filteredPhotos.length !== 1 ? 's' : ''} + {filteredPhotos.length} of {pagination.total} photo{pagination.total !== 1 ? 's' : ''} + {pagination.hasMore && • Loading more...}
@@ -205,7 +278,7 @@ export default function PhotoGrid({
{filteredPhotos.map((photo) => ( ))}
+ + {/* Load More Button/Indicator */} + {pagination.hasMore && !loading && ( +
+ {loadingMore ? ( + <> +
+

Loading more photos...

+ + ) : ( + + )} +
+ )} + + {/* End of Results */} + {!loading && !loadingMore && !pagination.hasMore && photos.length > 0 && ( +
+

You've reached the end of your photos

+

Showing all {pagination.total} photos

+
+ )} {/* Full Resolution Image Modal */} { + try { + return new Date(dateString).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + } catch { + return 'Unknown' + } + } + + // Get the best available date from EXIF data + const getBestDate = () => { + if (exif.date_time_original) return exif.date_time_original + if (exif.date_time_digitized) return exif.date_time_digitized + if (exif.date_time) return exif.date_time + if (photo.created_at) return photo.created_at + return null + } + const formatFileSize = (bytes: number) => { if (bytes === 0) return '0 B' const k = 1024 @@ -156,11 +179,13 @@ export default function PhotoThumbnail({
)} - {/* Date taken */} - {exif.date_time_original && ( + {/* Date taken with time */} + {getBestDate() && (
- {formatDate(exif.date_time_original)} + + {formatDateTime(getBestDate()!)} +
)}
@@ -173,6 +198,21 @@ export default function PhotoThumbnail({
{formatFileSize(photo.filesize)}
+ + {/* Third row: Additional EXIF date info for large thumbnails */} + {size === 'large' && (exif.date_time_original || exif.date_time_digitized || exif.date_time) && ( +
+ {exif.date_time_original && exif.date_time_original !== getBestDate() && ( +
📷 Taken: {formatDateTime(exif.date_time_original)}
+ )} + {exif.date_time_digitized && exif.date_time_digitized !== getBestDate() && ( +
💾 Digitized: {formatDateTime(exif.date_time_digitized)}
+ )} + {exif.date_time && exif.date_time !== getBestDate() && ( +
📝 Modified: {formatDateTime(exif.date_time)}
+ )} +
+ )} )} diff --git a/src/lib/database.ts b/src/lib/database.ts index 140730e..0c93c48 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -134,6 +134,33 @@ function initializeTables(database: Database.Database) { ) `) + // Create photo_conflicts table for files with same path but different content + database.exec(` + CREATE TABLE IF NOT EXISTS photo_conflicts ( + id TEXT PRIMARY KEY, + filename TEXT NOT NULL, + filepath TEXT NOT NULL, + directory TEXT NOT NULL, + filesize INTEGER NOT NULL, + created_at DATETIME NOT NULL, + modified_at DATETIME NOT NULL, + width INTEGER, + height INTEGER, + format TEXT, + favorite BOOLEAN DEFAULT FALSE, + rating INTEGER CHECK (rating >= 0 AND rating <= 5), + description TEXT, + metadata TEXT, -- JSON string + thumbnail_blob BLOB, -- Cached thumbnail image data + thumbnail_size INTEGER, -- Size parameter used for cached thumbnail + thumbnail_generated_at DATETIME, -- When thumbnail was generated + indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP, + conflict_detected_at DATETIME DEFAULT CURRENT_TIMESTAMP, + original_photo_id TEXT, -- Reference to the original photo record + conflict_reason TEXT -- Why this was flagged as a conflict + ) + `) + // Create indexes for better performance database.exec(` CREATE INDEX IF NOT EXISTS idx_photos_directory ON photos (directory); @@ -152,6 +179,9 @@ function initializeTables(database: Database.Database) { CREATE INDEX IF NOT EXISTS idx_photo_hashes_hash_id ON photo_hashes (hash_id); CREATE INDEX IF NOT EXISTS idx_photos_thumbnail_size ON photos (thumbnail_size); CREATE INDEX IF NOT EXISTS idx_photos_thumbnail_generated_at ON photos (thumbnail_generated_at); + CREATE INDEX IF NOT EXISTS idx_photo_conflicts_filepath ON photo_conflicts (filepath); + CREATE INDEX IF NOT EXISTS idx_photo_conflicts_original_photo_id ON photo_conflicts (original_photo_id); + CREATE INDEX IF NOT EXISTS idx_photo_conflicts_detected_at ON photo_conflicts (conflict_detected_at); `) } diff --git a/src/lib/file-scanner.ts b/src/lib/file-scanner.ts index ae2dfe4..642d789 100644 --- a/src/lib/file-scanner.ts +++ b/src/lib/file-scanner.ts @@ -131,16 +131,58 @@ async function processPhotoFile( let photoData: any = null try { - // Check if photo already exists - const existingPhoto = photoService.getPhotoByPath(filePath) - if (existingPhoto) { + stats = await stat(filePath) + + // Compute SHA256 hash first for conflict detection + console.log(`[FILE SCANNER] Computing SHA256 hash for: ${filename}`) + const sha256Hash = await computeFileHash(filePath) + console.log(`[FILE SCANNER] Computed hash for ${filename}: ${sha256Hash}`) + + // Check for conflicts with existing photos + const conflictCheck = photoService.checkForPhotoConflict(filePath, sha256Hash) + + if (conflictCheck.isDuplicate) { + console.info(`[FILE SCANNER] Skipping duplicate file (same path, same content): ${filename}`) result.photosSkipped++ return } - stats = await stat(filePath) + if (conflictCheck.hasConflict && conflictCheck.existingPhoto) { + console.warn(`[FILE SCANNER] CONFLICT DETECTED: File ${filename} has same path but different content than existing photo`) + + // Create basic photo record for the conflicting file + photoData = { + filename, + filepath: filePath, + directory: basePath, + filesize: stats.size, + created_at: stats.birthtime.toISOString(), + modified_at: stats.mtime.toISOString(), + favorite: false, + metadata: JSON.stringify({ + extension: extname(filename).toLowerCase(), + scanned_at: new Date().toISOString() + }) + } + + // Try to extract image metadata (width, height, format) + try { + const metadata = await extractImageMetadata(filePath) + Object.assign(photoData, metadata) + } catch (metadataError) { + console.warn(`[FILE SCANNER] Could not extract metadata for ${filePath}:`, metadataError) + } + + // Store in conflicts table + const conflictReason = `File has same path as existing photo but different SHA256 hash. Original hash: ${conflictCheck.existingPhoto.id}, New hash: ${sha256Hash}` + const conflictId = photoService.createPhotoConflict(photoData, conflictCheck.existingPhoto.id, conflictReason) + console.warn(`[FILE SCANNER] Stored conflict record with ID: ${conflictId}`) + + result.errors++ + return + } - // Create basic photo record + // Create basic photo record for new file photoData = { filename, filepath: filePath, @@ -177,12 +219,8 @@ async function processPhotoFile( result.photosAdded++ console.log(`[FILE SCANNER] Successfully added photo: ${filename}`) - // Compute and store SHA256 hash + // Store SHA256 hash try { - console.log(`[FILE SCANNER] Computing SHA256 hash for: ${filename}`) - const sha256Hash = await computeFileHash(filePath) - console.log(`[FILE SCANNER] Computed hash for ${filename}: ${sha256Hash}`) - // Create or update hash record const hashRecord = photoService.createOrUpdateImageHash(sha256Hash) @@ -191,8 +229,8 @@ async function processPhotoFile( console.log(`[FILE SCANNER] Associated photo with hash: ${associated}`) } catch (hashError) { - console.error(`[FILE SCANNER] Error computing hash for ${filePath}:`, hashError) - // Continue processing even if hash computation fails + console.error(`[FILE SCANNER] Error storing hash for ${filePath}:`, hashError) + // Continue processing even if hash storage fails } // Log progress every 100 files diff --git a/src/lib/photo-service.ts b/src/lib/photo-service.ts index 3fae914..5da3ec0 100644 --- a/src/lib/photo-service.ts +++ b/src/lib/photo-service.ts @@ -426,6 +426,85 @@ export class PhotoService { return result.changes > 0 } } + + // Conflict detection and handling methods + checkForPhotoConflict(filepath: string, sha256Hash: string): { hasConflict: boolean, existingPhoto?: Photo, isDuplicate: boolean } { + const existingPhoto = this.getPhotoByPath(filepath) + + if (!existingPhoto) { + return { hasConflict: false, isDuplicate: false } + } + + // Get the hash of the existing photo + const getPhotoHash = this.db.prepare(` + SELECT ih.sha256_hash + FROM photo_hashes ph + JOIN image_hashes ih ON ph.hash_id = ih.id + WHERE ph.photo_id = ? + `) + const existingHashRow = getPhotoHash.get(existingPhoto.id) as { sha256_hash: string } | null + + if (!existingHashRow) { + // Existing photo has no hash, assume it's different content + return { hasConflict: true, existingPhoto, isDuplicate: false } + } + + const existingHash = existingHashRow.sha256_hash + if (existingHash === sha256Hash) { + // Same file, same content - it's a duplicate + return { hasConflict: false, existingPhoto, isDuplicate: true } + } else { + // Same path, different content - it's a conflict + return { hasConflict: true, existingPhoto, isDuplicate: false } + } + } + + createPhotoConflict( + photoData: Omit, + originalPhotoId: string, + conflictReason: string + ): string { + const id = randomUUID() + const insertConflict = this.db.prepare(` + INSERT INTO photo_conflicts ( + id, filename, filepath, directory, filesize, created_at, modified_at, + width, height, format, favorite, rating, description, metadata, + original_photo_id, conflict_reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + try { + const params = [ + id, // string + photoData.filename, // string + photoData.filepath, // string + photoData.directory, // string + photoData.filesize, // number + photoData.created_at, // string + photoData.modified_at, // string + photoData.width || null, // number or null + photoData.height || null, // number or null + photoData.format || null, // string or null + photoData.favorite || false, // boolean + photoData.rating || null, // number or null + photoData.description || null, // string or null + photoData.metadata || null, // string (JSON) or null + originalPhotoId, // string + conflictReason // string + ] + + insertConflict.run(...params) + return id + } catch (error) { + console.error('Error creating photo conflict:', error) + throw new Error('Failed to create photo conflict record') + } + } + + getPhotoConflicts(): Array { + const selectConflicts = this.db.prepare('SELECT * FROM photo_conflicts ORDER BY conflict_detected_at DESC') + return selectConflicts.all() as Array + } } export const photoService = new PhotoService() \ No newline at end of file