photos/src/components/DirectoryModal.tsx
Michael Mainguy 5c3ad988f5 Add duplicate detection, conflict handling, and fix pagination issues
- Add photo_conflicts table for files with same path but different content
- Implement SHA256-based duplicate detection in file scanner
- Add conflict detection methods to PhotoService
- Skip identical files with info logging, store conflicts with warnings
- Fix infinite scroll pagination race conditions with functional state updates
- Add scroll throttling to prevent rapid API calls
- Enhance PhotoThumbnail with comprehensive EXIF date/time display
- Add composite React keys to prevent duplicate rendering issues

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 10:55:28 -05:00

267 lines
8.8 KiB
TypeScript

'use client'
import { useState, useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { IconCheck, IconX } from '@tabler/icons-react'
import Button from './Button'
interface DirectoryModalProps {
isOpen: boolean
onClose: () => void
onSave: (directory: string) => void
onDirectoryListRefresh?: () => void
}
export default function DirectoryModal({ isOpen, onClose, onSave, onDirectoryListRefresh }: DirectoryModalProps) {
const [directory, setDirectory] = useState('')
const [isValidating, setIsValidating] = useState(false)
const [isValid, setIsValid] = useState<boolean | null>(null)
const [validationTimeout, setValidationTimeout] = useState<NodeJS.Timeout | null>(null)
const [mounted, setMounted] = useState(false)
const [suggestions, setSuggestions] = useState<string[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1)
const suggestionRefs = useRef<(HTMLDivElement | null)[]>([])
useEffect(() => {
setMounted(true)
return () => setMounted(false)
}, [])
const validateDirectory = useCallback(async (path: string) => {
if (!path.trim()) {
setIsValid(null)
return
}
setIsValidating(true)
try {
const response = await fetch('/api/validate-directory', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ directory: path }),
})
const result = await response.json()
setIsValid(result.valid)
setSuggestions(result.suggestions || [])
setShowSuggestions(result.suggestions && result.suggestions.length > 0)
setSelectedSuggestionIndex(-1)
suggestionRefs.current = []
} catch (error) {
setIsValid(false)
} finally {
setIsValidating(false)
}
}, [])
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setDirectory(value)
setShowSuggestions(false) // Hide suggestions while typing
setSelectedSuggestionIndex(-1)
if (validationTimeout) {
clearTimeout(validationTimeout)
}
const timeout = setTimeout(() => {
validateDirectory(value)
}, 300)
setValidationTimeout(timeout)
}
const handleSuggestionClick = (suggestion: string) => {
setDirectory(suggestion)
setShowSuggestions(false)
setSelectedSuggestionIndex(-1)
validateDirectory(suggestion)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Handle Tab key to hide suggestions if directory is valid
if (e.key === 'Tab' && directory.trim() && isValid) {
setShowSuggestions(false)
setSelectedSuggestionIndex(-1)
return // Allow default Tab behavior
}
if (!showSuggestions || suggestions.length === 0) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedSuggestionIndex(prev => {
const newIndex = prev === -1 ? 0 : (prev < suggestions.length - 1 ? prev + 1 : 0)
setTimeout(() => {
suggestionRefs.current[newIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}, 0)
return newIndex
})
break
case 'ArrowUp':
e.preventDefault()
setSelectedSuggestionIndex(prev => {
const newIndex = prev > 0 ? prev - 1 : suggestions.length - 1
setTimeout(() => {
suggestionRefs.current[newIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}, 0)
return newIndex
})
break
case 'Enter':
e.preventDefault()
if (selectedSuggestionIndex >= 0 && selectedSuggestionIndex < suggestions.length) {
handleSuggestionClick(suggestions[selectedSuggestionIndex])
} else if (directory.trim() && isValid) {
handleSave()
}
break
case 'Escape':
setShowSuggestions(false)
setSelectedSuggestionIndex(-1)
break
}
}
if (!isOpen || !mounted) return null
console.log('Modal is rendering with isOpen:', isOpen)
const handleSave = async () => {
if (directory.trim() && isValid) {
try {
// Save directory to database
await fetch('/api/directories', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ path: directory.trim() }),
})
onSave(directory.trim())
onDirectoryListRefresh?.() // Trigger directory list refresh
setDirectory('')
setIsValid(null)
onClose()
} catch (error) {
console.error('Failed to save directory:', error)
// Still proceed with the save even if database save fails
onSave(directory.trim())
onDirectoryListRefresh?.() // Trigger directory list refresh
setDirectory('')
setIsValid(null)
onClose()
}
}
}
const handleClose = () => {
setDirectory('')
setIsValid(null)
setSuggestions([])
setShowSuggestions(false)
setSelectedSuggestionIndex(-1)
if (validationTimeout) {
clearTimeout(validationTimeout)
}
onClose()
}
const modalContent = (
<div className="fixed inset-0 bg-black bg-opacity-50 z-[9999] flex items-center justify-center p-4">
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="p-6 pb-4 border-b border-gray-200 dark:border-gray-600">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Select Directory to Scan
</h2>
</div>
{/* Content Area - Scrollable */}
<div className="flex-1 p-6 min-h-0">
<div className="relative">
<input
type="text"
value={directory}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => 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
/>
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
{isValidating && (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-600 border-t-transparent" />
)}
{!isValidating && isValid === true && (
<IconCheck className="h-4 w-4 text-green-600" />
)}
{!isValidating && (isValid === false || (directory && isValid === null)) && (
<IconX className="h-4 w-4 text-red-600" />
)}
</div>
{/* Suggestions - Now with better scrolling */}
{showSuggestions && suggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-lg max-h-64 overflow-y-auto z-10">
{suggestions.map((suggestion, index) => (
<div
key={index}
ref={(el) => { 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'
}`}
>
<div className="truncate" title={suggestion}>
{suggestion}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Fixed Footer with Buttons */}
<div className="p-6 pt-4 border-t border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-750 rounded-b-lg">
<div className="flex gap-2 justify-end">
<Button
onClick={handleClose}
variant="secondary"
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!isValid || isValidating}
variant="primary"
>
Save
</Button>
</div>
</div>
</div>
</div>
)
return createPortal(modalContent, document.body)
}