- Add arrow key navigation for autosuggestion dropdown - Add scroll-into-view for long suggestion lists - Add enter key selection for highlighted suggestions - Add README with macOS CIFS share access instructions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
227 lines
7.3 KiB
TypeScript
227 lines
7.3 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
|
|
}
|
|
|
|
export default function DirectoryModal({ isOpen, onClose, onSave }: 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>) => {
|
|
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 = () => {
|
|
if (directory.trim() && isValid) {
|
|
onSave(directory.trim())
|
|
setDirectory('')
|
|
setIsValid(null)
|
|
onClose()
|
|
}
|
|
}
|
|
|
|
const handleClose = () => {
|
|
setDirectory('')
|
|
setIsValid(null)
|
|
setSuggestions([])
|
|
setShowSuggestions(false)
|
|
setSelectedSuggestionIndex(-1)
|
|
if (validationTimeout) {
|
|
clearTimeout(validationTimeout)
|
|
}
|
|
onClose()
|
|
}
|
|
|
|
const modalContent = (
|
|
<div className="absolute inset-0 bg-black bg-opacity-50 z-[9999]" style={{ display: 'block', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, height: '100vh', width: '100vw' }}>
|
|
<div
|
|
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-800 p-6 rounded-lg w-96 shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
|
Select Directory to Scan
|
|
</h2>
|
|
|
|
<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>
|
|
|
|
{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-48 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'
|
|
}`}
|
|
>
|
|
{suggestion}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2 mt-4">
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={!isValid || isValidating}
|
|
variant="primary"
|
|
>
|
|
Save
|
|
</Button>
|
|
<Button
|
|
onClick={handleClose}
|
|
variant="secondary"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
return createPortal(modalContent, document.body)
|
|
} |