diff --git a/frontend/src/api/photos.ts b/frontend/src/api/photos.ts index 1eceb97..9346531 100644 --- a/frontend/src/api/photos.ts +++ b/frontend/src/api/photos.ts @@ -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 { diff --git a/frontend/src/pages/Process.tsx b/frontend/src/pages/Process.tsx index 11378af..33604ef 100644 --- a/frontend/src/pages/Process.tsx +++ b/frontend/src/pages/Process.tsx @@ -241,16 +241,38 @@ export default function Process() { - 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} />

diff --git a/frontend/src/pages/Scan.tsx b/frontend/src/pages/Scan.tsx index 6b518d6..71d1856 100644 --- a/frontend/src/pages/Scan.tsx +++ b/frontend/src/pages/Scan.tsx @@ -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(null) - const fileInputRef = useRef(null) const eventSourceRef = useRef(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) => { - 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() {

- Enter the full path to the folder containing photos + Enter the full path to the folder containing photos. + + 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). +

@@ -277,59 +296,6 @@ export default function Scan() { - {/* File Upload Section */} -
-

- Upload Photos -

- -
- - -
- - - -

- Drag and drop photos here, or{' '} - -

-

- Supports: JPG, PNG, BMP, TIFF -

-
-
-
- {/* Progress Section */} {(currentJob || jobProgress) && (
diff --git a/src/web/api/photos.py b/src/web/api/photos.py index c956b85..716e862 100644 --- a/src/web/api/photos.py +++ b/src/web/api/photos.py @@ -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."""