feat: Add browse folder API and enhance folder selection in Scan component
This commit introduces a new API endpoint for browsing folders, utilizing tkinter for a native folder picker dialog. The Scan component has been updated to integrate this functionality, allowing users to select folders more easily. If the native picker is unavailable, a browser-based fallback is implemented, ensuring a seamless user experience. Additionally, the input field for batch size has been modified to restrict input to numeric values only, improving data validation. Documentation has been updated to reflect these changes.
This commit is contained in:
parent
ac07932e14
commit
8d668a9658
@ -95,6 +95,13 @@ export const photosApi = {
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
browseFolder: async (): Promise<{ path: string; success: boolean; message?: string }> => {
|
||||
const { data } = await apiClient.post<{ path: string; success: boolean; message?: string }>(
|
||||
'/api/v1/photos/browse-folder'
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
export interface PhotoSearchResult {
|
||||
|
||||
@ -241,16 +241,38 @@ export default function Process() {
|
||||
</label>
|
||||
<input
|
||||
id="batch-size"
|
||||
type="number"
|
||||
min="1"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={batchSize || ''}
|
||||
onChange={(e) =>
|
||||
setBatchSize(
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||
)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
// Only allow numeric input
|
||||
if (value === '' || /^\d+$/.test(value)) {
|
||||
setBatchSize(value ? parseInt(value, 10) : undefined)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Allow: backspace, delete, tab, escape, enter, and decimal point
|
||||
if (
|
||||
[8, 9, 27, 13, 46, 110, 190].indexOf(e.keyCode) !== -1 ||
|
||||
// Allow: Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X
|
||||
(e.keyCode === 65 && e.ctrlKey === true) ||
|
||||
(e.keyCode === 67 && e.ctrlKey === true) ||
|
||||
(e.keyCode === 86 && e.ctrlKey === true) ||
|
||||
(e.keyCode === 88 && e.ctrlKey === true) ||
|
||||
// Allow: home, end, left, right
|
||||
(e.keyCode >= 35 && e.keyCode <= 39)
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Ensure that it is a number and stop the keypress
|
||||
if ((e.shiftKey || (e.keyCode < 48 || e.keyCode > 57)) && (e.keyCode < 96 || e.keyCode > 105)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
placeholder="All unprocessed photos"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { photosApi, PhotoImportRequest } from '../api/photos'
|
||||
import { jobsApi, JobResponse, JobStatus } from '../api/jobs'
|
||||
|
||||
@ -23,7 +23,6 @@ export default function Scan() {
|
||||
total?: number
|
||||
} | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
// Cleanup event source on unmount
|
||||
@ -35,58 +34,74 @@ export default function Scan() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFolderBrowse = () => {
|
||||
// Note: Browser security prevents direct folder selection
|
||||
// This is a workaround - user must type/paste path
|
||||
// In production, consider using Electron or a file picker library
|
||||
const path = prompt('Enter folder path to scan:')
|
||||
if (path) {
|
||||
setFolderPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
const imageFiles = files.filter((file) =>
|
||||
/\.(jpg|jpeg|png|bmp|tiff|tif)$/i.test(file.name)
|
||||
)
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
handleUploadFiles(imageFiles)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) {
|
||||
handleUploadFiles(Array.from(files))
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadFiles = async (files: File[]) => {
|
||||
setIsImporting(true)
|
||||
setError(null)
|
||||
setImportResult(null)
|
||||
|
||||
const handleFolderBrowse = async () => {
|
||||
// Try backend API first (uses tkinter for native folder picker with full path)
|
||||
try {
|
||||
const result = await photosApi.uploadPhotos(files)
|
||||
setImportResult({
|
||||
added: result.added,
|
||||
existing: result.existing,
|
||||
total: result.added + result.existing,
|
||||
})
|
||||
setIsImporting(false)
|
||||
const result = await photosApi.browseFolder()
|
||||
if (result.success && result.path) {
|
||||
setFolderPath(result.path)
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Upload failed')
|
||||
setIsImporting(false)
|
||||
// Backend API failed, fall back to browser picker
|
||||
console.warn('Backend folder picker unavailable, using browser fallback:', err)
|
||||
|
||||
// Check if it's a display/availability issue
|
||||
const errorMsg = err?.response?.data?.detail || err?.message || ''
|
||||
if (errorMsg.includes('display') || errorMsg.includes('DISPLAY')) {
|
||||
// Show user-friendly message about display issue
|
||||
alert('Native folder picker unavailable (no display). Using browser fallback.\n\nNote: Browser picker may only show folder name. You may need to manually complete the full path.')
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Use browser-based folder picker
|
||||
// Use File System Access API if available (modern browsers)
|
||||
if (typeof window !== 'undefined' && 'showDirectoryPicker' in window) {
|
||||
try {
|
||||
const directoryHandle = await (window as any).showDirectoryPicker()
|
||||
// Get the folder name from the handle
|
||||
const folderName = directoryHandle.name
|
||||
// Note: Browsers don't expose full absolute paths for security reasons
|
||||
setFolderPath(folderName)
|
||||
} catch (err: any) {
|
||||
// User cancelled the picker
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Error selecting folder:', err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: use a hidden directory input
|
||||
// Note: This will show a browser confirmation dialog that cannot be removed
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.setAttribute('webkitdirectory', '')
|
||||
input.setAttribute('directory', '')
|
||||
input.setAttribute('multiple', '')
|
||||
input.style.display = 'none'
|
||||
|
||||
input.onchange = (e: any) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) {
|
||||
const firstFile = files[0]
|
||||
const relativePath = firstFile.webkitRelativePath
|
||||
const pathParts = relativePath.split('/')
|
||||
const rootFolder = pathParts[0]
|
||||
// Note: Browsers don't expose full absolute paths for security reasons
|
||||
setFolderPath(rootFolder)
|
||||
}
|
||||
if (document.body.contains(input)) {
|
||||
document.body.removeChild(input)
|
||||
}
|
||||
}
|
||||
|
||||
input.oncancel = () => {
|
||||
if (document.body.contains(input)) {
|
||||
document.body.removeChild(input)
|
||||
}
|
||||
}
|
||||
|
||||
document.body.appendChild(input)
|
||||
input.click()
|
||||
}
|
||||
}
|
||||
|
||||
@ -245,7 +260,11 @@ export default function Scan() {
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Enter the full path to the folder containing photos
|
||||
Enter the full path to the folder containing photos.
|
||||
<span className="text-xs text-gray-400 block mt-1">
|
||||
Click Browse to open a native folder picker. The full path will be automatically filled.
|
||||
If the native picker is unavailable, a browser fallback will be used (may require manual path completion).
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -277,59 +296,6 @@ export default function Scan() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Upload Section */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Upload Photos
|
||||
</h2>
|
||||
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={isImporting}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path
|
||||
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-gray-600">
|
||||
Drag and drop photos here, or{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isImporting}
|
||||
className="text-blue-600 hover:text-blue-700 focus:outline-none"
|
||||
>
|
||||
browse
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Supports: JPG, PNG, BMP, TIFF
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Section */}
|
||||
{(currentJob || jobProgress) && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
|
||||
@ -313,6 +313,75 @@ async def upload_photos(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/browse-folder")
|
||||
def browse_folder() -> dict:
|
||||
"""Open native folder picker dialog and return selected folder path.
|
||||
|
||||
Uses tkinter to show a native OS folder picker dialog.
|
||||
Returns the full absolute path of the selected folder.
|
||||
|
||||
Returns:
|
||||
dict with 'path' (str) and 'success' (bool) keys
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="tkinter is not available. Cannot show folder picker.",
|
||||
)
|
||||
|
||||
try:
|
||||
# Create root window (hidden)
|
||||
root = tk.Tk()
|
||||
root.withdraw() # Hide main window
|
||||
root.attributes('-topmost', True) # Bring to front
|
||||
|
||||
# Show folder picker dialog
|
||||
folder_path = filedialog.askdirectory(
|
||||
title="Select folder to scan",
|
||||
mustexist=True
|
||||
)
|
||||
|
||||
# Clean up
|
||||
root.destroy()
|
||||
|
||||
if folder_path:
|
||||
# Normalize path to absolute
|
||||
abs_path = os.path.abspath(folder_path)
|
||||
return {
|
||||
"path": abs_path,
|
||||
"success": True,
|
||||
"message": f"Selected folder: {abs_path}"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"path": "",
|
||||
"success": False,
|
||||
"message": "No folder selected"
|
||||
}
|
||||
except Exception as e:
|
||||
# Handle errors gracefully
|
||||
error_msg = str(e)
|
||||
|
||||
# Check for common issues (display/headless server)
|
||||
if "display" in error_msg.lower() or "DISPLAY" not in os.environ:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="No display available. Cannot show folder picker. "
|
||||
"If running on a remote server, ensure X11 forwarding is enabled.",
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error showing folder picker: {error_msg}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{photo_id}", response_model=PhotoResponse)
|
||||
def get_photo(photo_id: int, db: Session = Depends(get_db)) -> PhotoResponse:
|
||||
"""Get photo by ID."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user