From 46dffc6ade8bad4cb63eecc4962928510da2b78e Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Fri, 30 Jan 2026 19:06:57 +0000 Subject: [PATCH] - Add radio buttons to choose between 'Scan from Local' and 'Scan from Network' - Local mode: Use File System Access API (Chrome/Edge/Safari) or webkitdirectory (Firefox) to read folders from browser - Local mode: Browser reads files and uploads them via HTTP (no server-side filesystem access needed) - Network mode: Type network paths or use Browse Network button for server-side scanning - Add 'Start Scanning' button for local mode (separate from folder selection) - Update API client to handle FormData uploads correctly (remove Content-Type header) - Update Help page documentation with new scan mode options - Add progress tracking for local file uploads" --- admin-frontend/src/api/client.ts | 4 + admin-frontend/src/api/photos.ts | 3 +- admin-frontend/src/pages/Help.tsx | 84 +++++- admin-frontend/src/pages/Scan.tsx | 418 ++++++++++++++++++++++++++---- 4 files changed, 446 insertions(+), 63 deletions(-) diff --git a/admin-frontend/src/api/client.ts b/admin-frontend/src/api/client.ts index ccbe58d..fb6729d 100644 --- a/admin-frontend/src/api/client.ts +++ b/admin-frontend/src/api/client.ts @@ -22,6 +22,10 @@ apiClient.interceptors.request.use((config) => { if (token) { config.headers.Authorization = `Bearer ${token}` } + // Remove Content-Type header for FormData - axios will set it automatically with boundary + if (config.data instanceof FormData) { + delete config.headers['Content-Type'] + } return config }) diff --git a/admin-frontend/src/api/photos.ts b/admin-frontend/src/api/photos.ts index 8bad433..2a16423 100644 --- a/admin-frontend/src/api/photos.ts +++ b/admin-frontend/src/api/photos.ts @@ -54,7 +54,8 @@ export const photosApi = { formData.append('files', file) }) - // Don't set Content-Type header manually - let the browser set it with boundary + // The interceptor will automatically remove Content-Type for FormData + // Axios will set multipart/form-data with boundary automatically const { data } = await apiClient.post( '/api/v1/photos/import/upload', formData diff --git a/admin-frontend/src/pages/Help.tsx b/admin-frontend/src/pages/Help.tsx index 9e7b39f..07dd36b 100644 --- a/admin-frontend/src/pages/Help.tsx +++ b/admin-frontend/src/pages/Help.tsx @@ -167,39 +167,95 @@ function ScanPageHelp({ onBack }: { onBack: () => void }) {

Purpose

-

Import photos into your collection from folders or upload files

+

Import photos into your collection from folders. Choose between scanning from your local computer or from network paths.

+
+
+

Scan Modes

+
+

Scan from Local:

+
    +
  • Select folders from your local computer using the browser
  • +
  • Works with File System Access API (Chrome, Edge, Safari) or webkitdirectory (Firefox)
  • +
  • The browser reads files and uploads them to the server
  • +
  • No server-side filesystem access needed
  • +
  • Perfect for scanning folders on your local machine
  • +
+
+
+

Scan from Network:

+
    +
  • Scan folders on network shares (UNC paths, mounted NFS/SMB shares)
  • +
  • Type the network path directly or use "Browse Network" to navigate
  • +
  • The server accesses the filesystem directly
  • +
  • Requires the backend server to have access to the network path
  • +
  • Perfect for scanning folders on network drives or mounted shares
  • +
+

Features

    -
  • Folder Selection: Browse and select folders containing photos
  • +
  • Scan Mode Selection: Choose between "Scan from Local" or "Scan from Network"
  • +
  • Local Folder Selection: Use browser's folder picker to select folders from your computer
  • +
  • Network Path Input: Type network paths directly or browse network shares
  • Recursive Scanning: Option to scan subdirectories recursively (enabled by default)
  • Duplicate Detection: Automatically detects and skips duplicate photos
  • Real-time Progress: Live progress tracking during import
  • -

How to Use

-

Folder Scan:

-
    -
  1. Click "Browse Folder" button
  2. -
  3. Select a folder containing photos
  4. -
  5. Toggle "Subdirectories recursively" if you want to include subfolders (enabled by default)
  6. -
  7. Click "Start Scan" button
  8. -
  9. Monitor progress in the progress bar
  10. -
  11. View results (photos added, existing photos skipped)
  12. -
+
+

Scan from Local:

+
    +
  1. Select "Scan from Local" radio button
  2. +
  3. Click "Select Folder" button
  4. +
  5. Choose a folder from your local computer using the folder picker
  6. +
  7. The selected folder name will appear in the input field
  8. +
  9. Toggle "Scan subdirectories recursively" if you want to include subfolders (enabled by default)
  10. +
  11. Click "Start Scanning" button to begin the upload
  12. +
  13. Monitor progress in the progress bar
  14. +
  15. View results (photos added, existing photos skipped)
  16. +
+
+
+

Scan from Network:

+
    +
  1. Select "Scan from Network" radio button
  2. +
  3. Either: +
      +
    • Type the network path directly (e.g., \\server\share or /mnt/nfs-share)
    • +
    • Or click "Browse Network" to navigate network shares visually
    • +
    +
  4. +
  5. Toggle "Scan subdirectories recursively" if you want to include subfolders (enabled by default)
  6. +
  7. Click "Start Scanning" button
  8. +
  9. Monitor progress in the progress bar
  10. +
  11. View results (photos added, existing photos skipped)
  12. +
+

What Happens

    - +
  • Local Mode: Browser reads files from your computer and uploads them to the server via HTTP
  • +
  • Network Mode: Server accesses files directly from the network path
  • Photos are added to database
  • +
  • Duplicate photos are automatically skipped
  • Faces are NOT detected yet (use Process page for that)
- +
+

Tips

+
    +
  • Use "Scan from Local" for folders on your computer - works in all modern browsers
  • +
  • Use "Scan from Network" for folders on network drives or mounted shares
  • +
  • Recursive scanning is enabled by default - uncheck if you only want the top-level folder
  • +
  • Large folders may take time to scan - be patient and monitor the progress
  • +
  • Duplicate detection prevents adding the same photo twice
  • +
  • After scanning, use the Process page to detect faces in the imported photos
  • +
+
) diff --git a/admin-frontend/src/pages/Scan.tsx b/admin-frontend/src/pages/Scan.tsx index 068f54e..7f5dd37 100644 --- a/admin-frontend/src/pages/Scan.tsx +++ b/admin-frontend/src/pages/Scan.tsx @@ -12,11 +12,64 @@ interface JobProgress { total?: number } +type ScanMode = 'network' | 'local' + +// Supported image and video extensions for File System Access API +const SUPPORTED_EXTENSIONS = [ + '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif', + '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.flv', '.wmv' +] + +// Check if File System Access API is supported +const isFileSystemAccessSupported = (): boolean => { + return 'showDirectoryPicker' in window +} + +// Check if webkitdirectory (fallback) is supported +const isWebkitDirectorySupported = (): boolean => { + const input = document.createElement('input') + return 'webkitdirectory' in input +} + +// Recursively read all files from a directory handle +async function readDirectoryRecursive( + dirHandle: FileSystemDirectoryHandle, + recursive: boolean = true +): Promise { + const files: File[] = [] + + async function traverse(handle: FileSystemDirectoryHandle, path: string = '') { + // @ts-ignore - File System Access API types may not be available + for await (const entry of handle.values()) { + if (entry.kind === 'file') { + const file = await entry.getFile() + const ext = '.' + file.name.split('.').pop()?.toLowerCase() + if (SUPPORTED_EXTENSIONS.includes(ext)) { + files.push(file) + } + } else if (entry.kind === 'directory' && recursive) { + await traverse(entry, path + '/' + entry.name) + } + } + } + + await traverse(dirHandle) + return files +} + export default function Scan() { + const [scanMode, setScanMode] = useState('local') const [folderPath, setFolderPath] = useState('') const [recursive, setRecursive] = useState(true) const [isImporting, setIsImporting] = useState(false) const [showFolderBrowser, setShowFolderBrowser] = useState(false) + const [localUploadProgress, setLocalUploadProgress] = useState<{ + current: number + total: number + filename: string + } | null>(null) + const [selectedFiles, setSelectedFiles] = useState([]) + const fileInputRef = useRef(null) const [currentJob, setCurrentJob] = useState(null) const [jobProgress, setJobProgress] = useState(null) const [importResult, setImportResult] = useState<{ @@ -46,39 +99,175 @@ export default function Scan() { setError(null) } - const handleScanFolder = async () => { - if (!folderPath.trim()) { - setError('Please enter a folder path') + const handleLocalFolderSelect = (files: FileList | null) => { + if (!files || files.length === 0) { return } - setIsImporting(true) setError(null) setImportResult(null) setCurrentJob(null) setJobProgress(null) + setLocalUploadProgress(null) + + // Filter to only supported files + const fileArray = Array.from(files).filter((file) => { + const ext = '.' + file.name.split('.').pop()?.toLowerCase() + return SUPPORTED_EXTENSIONS.includes(ext) + }) + + if (fileArray.length === 0) { + setError('No supported image or video files found in the selected folder.') + setSelectedFiles([]) + return + } + + // Set folder path from first file's path + if (fileArray.length > 0) { + const firstFile = fileArray[0] + // Extract folder path from file path (webkitdirectory includes full path) + const folderPath = firstFile.webkitRelativePath.split('/').slice(0, -1).join('/') + setFolderPath(folderPath || 'Selected folder') + } + + // Store files for later upload + setSelectedFiles(fileArray) + } + + const handleStartLocalScan = async () => { + if (selectedFiles.length === 0) { + setError('Please select a folder first.') + return + } try { - const request: PhotoImportRequest = { - folder_path: folderPath.trim(), - recursive, + setIsImporting(true) + setError(null) + setImportResult(null) + setCurrentJob(null) + setJobProgress(null) + setLocalUploadProgress(null) + + // Upload files to backend in batches to show progress + setLocalUploadProgress({ current: 0, total: selectedFiles.length, filename: '' }) + + // Upload files in batches to show progress + const batchSize = 10 + let uploaded = 0 + let totalAdded = 0 + let totalExisting = 0 + + for (let i = 0; i < selectedFiles.length; i += batchSize) { + const batch = selectedFiles.slice(i, i + batchSize) + const response = await photosApi.uploadPhotos(batch) + + uploaded += batch.length + totalAdded += response.added || 0 + totalExisting += response.existing || 0 + + setLocalUploadProgress({ + current: uploaded, + total: selectedFiles.length, + filename: batch[batch.length - 1]?.name || '', + }) + } + + setImportResult({ + added: totalAdded, + existing: totalExisting, + total: selectedFiles.length, + }) + + setIsImporting(false) + setLocalUploadProgress(null) + } catch (err: any) { + setError(err.response?.data?.detail || err.message || 'Failed to upload files') + setIsImporting(false) + setLocalUploadProgress(null) + } + } + + const handleScanFolder = async () => { + if (scanMode === 'local') { + // For local mode, use File System Access API if available, otherwise fallback to webkitdirectory + if (isFileSystemAccessSupported()) { + // Use File System Access API (Chrome, Edge, Safari) + try { + setIsImporting(true) + setError(null) + setImportResult(null) + setCurrentJob(null) + setJobProgress(null) + setLocalUploadProgress(null) + + // Show directory picker + // @ts-ignore - File System Access API types may not be available + const dirHandle = await window.showDirectoryPicker() + const folderName = dirHandle.name + setFolderPath(folderName) + + // Read all files from the directory + const files = await readDirectoryRecursive(dirHandle, recursive) + + if (files.length === 0) { + setError('No supported image or video files found in the selected folder.') + setSelectedFiles([]) + return + } + + // Store files for later upload + setSelectedFiles(files) + } catch (err: any) { + if (err.name === 'AbortError') { + // User cancelled the folder picker + setError(null) + setSelectedFiles([]) + } else { + setError(err.message || 'Failed to select folder') + setSelectedFiles([]) + } + } + } else if (isWebkitDirectorySupported()) { + // Fallback: Use webkitdirectory input (Firefox, older browsers) + fileInputRef.current?.click() + } else { + setError('Folder selection is not supported in your browser. Please use Chrome, Edge, Safari, or Firefox.') + } + } else { + // For network mode, use the existing path-based import + if (!folderPath.trim()) { + setError('Please enter a folder path') + return } - const response = await photosApi.importPhotos(request) - setCurrentJob({ - id: response.job_id, - status: JobStatus.PENDING, - progress: 0, - message: response.message, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }) + setIsImporting(true) + setError(null) + setImportResult(null) + setCurrentJob(null) + setJobProgress(null) - // Start SSE stream for job progress - startJobProgressStream(response.job_id) - } catch (err: any) { - setError(err.response?.data?.detail || err.message || 'Import failed') - setIsImporting(false) + try { + const request: PhotoImportRequest = { + folder_path: folderPath.trim(), + recursive, + } + + const response = await photosApi.importPhotos(request) + setCurrentJob({ + id: response.job_id, + status: JobStatus.PENDING, + progress: 0, + message: response.message, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + + // Start SSE stream for job progress + startJobProgressStream(response.job_id) + } catch (err: any) { + setError(err.response?.data?.detail || err.message || 'Import failed') + setIsImporting(false) + } } } @@ -173,34 +362,123 @@ export default function Scan() {
+ {/* Scan Mode Selection */} +
+ +
+ + +
+
+
- setFolderPath(e.target.value)} - placeholder="/path/to/photos" - className="w-1/2 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" - disabled={isImporting} - /> - + {scanMode === 'local' ? ( + <> + + handleLocalFolderSelect(e.target.files)} + /> + + + ) : ( + <> + setFolderPath(e.target.value)} + placeholder="Type network path: \\\\server\\share or /mnt/nfs-share" + className="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + disabled={isImporting} + /> + + + )}

- Enter the full absolute path to the folder containing photos / videos. + {scanMode === 'local' ? ( + <> + Click "Select Folder" to choose a folder from your local computer. The browser will read the files and upload them to the server. + {!isFileSystemAccessSupported() && !isWebkitDirectorySupported() && ( + + ⚠️ Folder selection is not supported in your browser. Please use Chrome, Edge, Safari, or Firefox. + + )} + + ) : ( + <> + Type a network folder path directly (e.g., \\server\share or /mnt/nfs-share), or click "Browse Network" to navigate network shares. + + )}

@@ -221,17 +499,61 @@ export default function Scan() {
- + {scanMode === 'local' && ( + + )} + {scanMode === 'network' && ( + + )} + {/* Local Upload Progress Section */} + {localUploadProgress && ( +
+

+ Upload Progress +

+
+
+
+ + Uploading files... + + + {localUploadProgress.current} / {localUploadProgress.total} + +
+
+
+
+
+ {localUploadProgress.filename && ( +
+

Current file: {localUploadProgress.filename}

+
+ )} +
+
+ )} + {/* Progress Section */} {(currentJob || jobProgress) && (