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