PunimTag Web Application - Major Feature Release #1

Open
tanyar09 wants to merge 106 commits from dev into master
4 changed files with 446 additions and 63 deletions
Showing only changes of commit 46dffc6ade - Show all commits

View File

@ -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
})

View File

@ -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<UploadResponse>(
'/api/v1/photos/import/upload',
formData

View File

@ -167,39 +167,95 @@ function ScanPageHelp({ onBack }: { onBack: () => void }) {
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Purpose</h3>
<p className="text-gray-700">Import photos into your collection from folders or upload files</p>
<p className="text-gray-700">Import photos into your collection from folders. Choose between scanning from your local computer or from network paths.</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Scan Modes</h3>
<div className="mb-3">
<p className="text-gray-700 font-medium mb-2">Scan from Local:</p>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Select folders from your local computer using the browser</li>
<li>Works with File System Access API (Chrome, Edge, Safari) or webkitdirectory (Firefox)</li>
<li>The browser reads files and uploads them to the server</li>
<li>No server-side filesystem access needed</li>
<li>Perfect for scanning folders on your local machine</li>
</ul>
</div>
<div className="mb-3">
<p className="text-gray-700 font-medium mb-2">Scan from Network:</p>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Scan folders on network shares (UNC paths, mounted NFS/SMB shares)</li>
<li>Type the network path directly or use "Browse Network" to navigate</li>
<li>The server accesses the filesystem directly</li>
<li>Requires the backend server to have access to the network path</li>
<li>Perfect for scanning folders on network drives or mounted shares</li>
</ul>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Features</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li><strong>Folder Selection:</strong> Browse and select folders containing photos</li>
<li><strong>Scan Mode Selection:</strong> Choose between "Scan from Local" or "Scan from Network"</li>
<li><strong>Local Folder Selection:</strong> Use browser's folder picker to select folders from your computer</li>
<li><strong>Network Path Input:</strong> Type network paths directly or browse network shares</li>
<li><strong>Recursive Scanning:</strong> Option to scan subdirectories recursively (enabled by default)</li>
<li><strong>Duplicate Detection:</strong> Automatically detects and skips duplicate photos</li>
<li><strong>Real-time Progress:</strong> Live progress tracking during import</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">How to Use</h3>
<p className="text-gray-700 font-medium mb-2">Folder Scan:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Click "Browse Folder" button</li>
<li>Select a folder containing photos</li>
<li>Toggle "Subdirectories recursively" if you want to include subfolders (enabled by default)</li>
<li>Click "Start Scan" button</li>
<li>Monitor progress in the progress bar</li>
<li>View results (photos added, existing photos skipped)</li>
</ol>
<div className="mb-3">
<p className="text-gray-700 font-medium mb-2">Scan from Local:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Select "Scan from Local" radio button</li>
<li>Click "Select Folder" button</li>
<li>Choose a folder from your local computer using the folder picker</li>
<li>The selected folder name will appear in the input field</li>
<li>Toggle "Scan subdirectories recursively" if you want to include subfolders (enabled by default)</li>
<li>Click "Start Scanning" button to begin the upload</li>
<li>Monitor progress in the progress bar</li>
<li>View results (photos added, existing photos skipped)</li>
</ol>
</div>
<div className="mb-3">
<p className="text-gray-700 font-medium mb-2">Scan from Network:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Select "Scan from Network" radio button</li>
<li>Either:
<ul className="list-disc list-inside ml-4 mt-1">
<li>Type the network path directly (e.g., <code className="text-xs bg-gray-100 px-1 py-0.5 rounded">\\server\share</code> or <code className="text-xs bg-gray-100 px-1 py-0.5 rounded">/mnt/nfs-share</code>)</li>
<li>Or click "Browse Network" to navigate network shares visually</li>
</ul>
</li>
<li>Toggle "Scan subdirectories recursively" if you want to include subfolders (enabled by default)</li>
<li>Click "Start Scanning" button</li>
<li>Monitor progress in the progress bar</li>
<li>View results (photos added, existing photos skipped)</li>
</ol>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">What Happens</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li><strong>Local Mode:</strong> Browser reads files from your computer and uploads them to the server via HTTP</li>
<li><strong>Network Mode:</strong> Server accesses files directly from the network path</li>
<li>Photos are added to database</li>
<li>Duplicate photos are automatically skipped</li>
<li>Faces are NOT detected yet (use Process page for that)</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Tips</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Use "Scan from Local" for folders on your computer - works in all modern browsers</li>
<li>Use "Scan from Network" for folders on network drives or mounted shares</li>
<li>Recursive scanning is enabled by default - uncheck if you only want the top-level folder</li>
<li>Large folders may take time to scan - be patient and monitor the progress</li>
<li>Duplicate detection prevents adding the same photo twice</li>
<li>After scanning, use the Process page to detect faces in the imported photos</li>
</ul>
</div>
</div>
</PageHelpLayout>
)

View File

@ -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<File[]> {
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<ScanMode>('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<File[]>([])
const fileInputRef = useRef<HTMLInputElement | null>(null)
const [currentJob, setCurrentJob] = useState<JobResponse | null>(null)
const [jobProgress, setJobProgress] = useState<JobProgress | null>(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() {
</h2>
<div className="space-y-4">
{/* Scan Mode Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Scan Mode
</label>
<div className="flex gap-6">
<label className="flex items-center">
<input
type="radio"
name="scan-mode"
value="local"
checked={scanMode === 'local'}
onChange={() => {
setScanMode('local')
setFolderPath('')
setSelectedFiles([])
setError(null)
}}
disabled={isImporting}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="ml-2 text-sm text-gray-700">Scan from Local</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="scan-mode"
value="network"
checked={scanMode === 'network'}
onChange={() => {
setScanMode('network')
setFolderPath('')
setSelectedFiles([])
setError(null)
}}
disabled={isImporting}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="ml-2 text-sm text-gray-700">Scan from Network</span>
</label>
</div>
</div>
<div>
<label
htmlFor="folder-path"
className="block text-sm font-medium text-gray-700 mb-2"
>
Folder Path
{scanMode === 'local' ? 'Selected Folder' : 'Folder or File Path'}
</label>
<div className="flex gap-2">
<input
id="folder-path"
type="text"
value={folderPath}
onChange={(e) => 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}
/>
<button
type="button"
onClick={handleFolderBrowse}
disabled={isImporting}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Browse
</button>
{scanMode === 'local' ? (
<>
<input
id="folder-path"
type="text"
value={folderPath || 'No folder selected'}
readOnly
className="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-50 text-gray-600"
disabled={isImporting}
/>
<input
ref={fileInputRef}
type="file"
{...({ webkitdirectory: '', directory: '' } as any)}
multiple
style={{ display: 'none' }}
onChange={(e) => handleLocalFolderSelect(e.target.files)}
/>
<button
type="button"
onClick={handleScanFolder}
disabled={isImporting || (!isFileSystemAccessSupported() && !isWebkitDirectorySupported())}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
title="Select folder from your local computer"
>
{isImporting ? 'Scanning...' : 'Select Folder'}
</button>
</>
) : (
<>
<input
id="folder-path"
type="text"
value={folderPath}
onChange={(e) => 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}
/>
<button
type="button"
onClick={handleFolderBrowse}
disabled={isImporting}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
title="Browse network paths (UNC paths, mounted shares)"
>
Browse Network
</button>
</>
)}
</div>
<p className="mt-1 text-sm text-gray-500">
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() && (
<span className="text-orange-600 block mt-1">
Folder selection is not supported in your browser. Please use Chrome, Edge, Safari, or Firefox.
</span>
)}
</>
) : (
<>
Type a network folder path directly (e.g., <code className="text-xs bg-gray-100 px-1 py-0.5 rounded">\\server\share</code> or <code className="text-xs bg-gray-100 px-1 py-0.5 rounded">/mnt/nfs-share</code>), or click "Browse Network" to navigate network shares.
</>
)}
</p>
</div>
@ -221,17 +499,61 @@ export default function Scan() {
</label>
</div>
<button
type="button"
onClick={handleScanFolder}
disabled={isImporting || !folderPath.trim()}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImporting ? 'Scanning...' : 'Start Scanning'}
</button>
{scanMode === 'local' && (
<button
type="button"
onClick={handleStartLocalScan}
disabled={isImporting || selectedFiles.length === 0}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImporting ? 'Scanning...' : 'Start Scanning'}
</button>
)}
{scanMode === 'network' && (
<button
type="button"
onClick={handleScanFolder}
disabled={isImporting || !folderPath.trim()}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImporting ? 'Scanning...' : 'Start Scanning'}
</button>
)}
</div>
</div>
{/* Local Upload Progress Section */}
{localUploadProgress && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Upload Progress
</h2>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-blue-600">
Uploading files...
</span>
<span className="text-sm text-gray-600">
{localUploadProgress.current} / {localUploadProgress.total}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${(localUploadProgress.current / localUploadProgress.total) * 100}%` }}
/>
</div>
</div>
{localUploadProgress.filename && (
<div className="text-sm text-gray-600">
<p>Current file: {localUploadProgress.filename}</p>
</div>
)}
</div>
</div>
)}
{/* Progress Section */}
{(currentJob || jobProgress) && (
<div className="bg-white rounded-lg shadow p-6">