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:
tanyar09 2025-11-10 14:12:51 -05:00
parent ac07932e14
commit 8d668a9658
4 changed files with 178 additions and 114 deletions

View File

@ -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 {

View File

@ -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">

View File

@ -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">

View File

@ -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."""