migration to web

This commit is contained in:
tanyar09
2025-10-31 12:23:19 -04:00
parent 94385e3dcc
commit 4c2148f7fc
12 changed files with 1103 additions and 25 deletions
+28
View File
@@ -0,0 +1,28 @@
import apiClient from './client'
export enum JobStatus {
PENDING = 'pending',
STARTED = 'started',
PROGRESS = 'progress',
SUCCESS = 'success',
FAILURE = 'failure',
}
export interface JobResponse {
id: string
status: JobStatus
progress: number
message: string
created_at: string
updated_at: string
}
export const jobsApi = {
getJob: async (jobId: string): Promise<JobResponse> => {
const { data } = await apiClient.get<JobResponse>(
`/api/v1/jobs/${jobId}`
)
return data
},
}
+76
View File
@@ -0,0 +1,76 @@
import apiClient from './client'
import { JobResponse } from './jobs'
export interface PhotoImportRequest {
folder_path: string
recursive?: boolean
}
export interface PhotoImportResponse {
job_id: string
message: string
folder_path?: string
estimated_photos?: number
}
export interface PhotoResponse {
id: number
path: string
filename: string
checksum?: string
date_added: string
date_taken?: string
width?: number
height?: number
mime_type?: string
}
export interface UploadResponse {
message: string
added: number
existing: number
errors: string[]
}
export const photosApi = {
importPhotos: async (
request: PhotoImportRequest
): Promise<PhotoImportResponse> => {
const { data } = await apiClient.post<PhotoImportResponse>(
'/api/v1/photos/import',
request
)
return data
},
uploadPhotos: async (files: File[]): Promise<UploadResponse> => {
const formData = new FormData()
files.forEach((file) => {
formData.append('files', file)
})
const { data } = await apiClient.post<UploadResponse>(
'/api/v1/photos/import/upload',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return data
},
getPhoto: async (photoId: number): Promise<PhotoResponse> => {
const { data } = await apiClient.get<PhotoResponse>(
`/api/v1/photos/${photoId}`
)
return data
},
streamJobProgress: (jobId: string): EventSource => {
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return new EventSource(`${baseURL}/api/v1/jobs/stream/${jobId}`)
},
}
+413 -6
View File
@@ -1,12 +1,419 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { photosApi, PhotoImportRequest } from '../api/photos'
import { jobsApi, JobResponse, JobStatus } from '../api/jobs'
interface JobProgress {
id: string
status: string
progress: number
message: string
processed?: number
total?: number
}
export default function Scan() {
const [folderPath, setFolderPath] = useState('')
const [recursive, setRecursive] = useState(true)
const [isImporting, setIsImporting] = useState(false)
const [currentJob, setCurrentJob] = useState<JobResponse | null>(null)
const [jobProgress, setJobProgress] = useState<JobProgress | null>(null)
const [importResult, setImportResult] = useState<{
added?: number
existing?: number
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
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
}
}, [])
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)
try {
const result = await photosApi.uploadPhotos(files)
setImportResult({
added: result.added,
existing: result.existing,
total: result.added + result.existing,
})
setIsImporting(false)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Upload failed')
setIsImporting(false)
}
}
const handleScanFolder = async () => {
if (!folderPath.trim()) {
setError('Please enter a folder path')
return
}
setIsImporting(true)
setError(null)
setImportResult(null)
setCurrentJob(null)
setJobProgress(null)
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)
}
}
const startJobProgressStream = (jobId: string) => {
// Close existing stream if any
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
const eventSource = photosApi.streamJobProgress(jobId)
eventSourceRef.current = eventSource
eventSource.onmessage = (event) => {
try {
const data: JobProgress = JSON.parse(event.data)
setJobProgress(data)
// Update job status
const statusMap: Record<string, JobStatus> = {
pending: JobStatus.PENDING,
started: JobStatus.STARTED,
progress: JobStatus.PROGRESS,
success: JobStatus.SUCCESS,
failure: JobStatus.FAILURE,
}
setCurrentJob({
id: data.id,
status: statusMap[data.status] || JobStatus.PENDING,
progress: data.progress,
message: data.message,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
// Check if job is complete
if (data.status === 'success' || data.status === 'failure') {
setIsImporting(false)
eventSource.close()
eventSourceRef.current = null
// Fetch final job result to get added/existing counts
if (data.status === 'success') {
fetchJobResult(jobId)
}
}
} catch (err) {
console.error('Error parsing SSE event:', err)
}
}
eventSource.onerror = (err) => {
console.error('SSE error:', err)
eventSource.close()
eventSourceRef.current = null
}
}
const fetchJobResult = async (jobId: string) => {
try {
const job = await jobsApi.getJob(jobId)
// Job result may contain added/existing counts in metadata
// For now, we'll just update the job status
setCurrentJob(job)
} catch (err) {
console.error('Error fetching job result:', err)
}
}
const getStatusColor = (status: JobStatus) => {
switch (status) {
case JobStatus.SUCCESS:
return 'text-green-600'
case JobStatus.FAILURE:
return 'text-red-600'
case JobStatus.STARTED:
case JobStatus.PROGRESS:
return 'text-blue-600'
default:
return 'text-gray-600'
}
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">Scan</h1>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-gray-600">Folder scanning UI coming in Phase 2.</p>
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Scan Photos</h1>
<div className="space-y-6">
{/* Folder Scan Section */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Scan Folder
</h2>
<div className="space-y-4">
<div>
<label
htmlFor="folder-path"
className="block text-sm font-medium text-gray-700 mb-2"
>
Folder 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="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"
>
Browse
</button>
</div>
<p className="mt-1 text-sm text-gray-500">
Enter the full path to the folder containing photos
</p>
</div>
<div className="flex items-center">
<input
id="recursive"
type="checkbox"
checked={recursive}
onChange={(e) => setRecursive(e.target.checked)}
disabled={isImporting}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label
htmlFor="recursive"
className="ml-2 block text-sm text-gray-700"
>
Scan subdirectories recursively
</label>
</div>
<button
type="button"
onClick={handleScanFolder}
disabled={isImporting || !folderPath.trim()}
className="w-full 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"
>
{isImporting ? 'Scanning...' : 'Start Scan'}
</button>
</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">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Import Progress
</h2>
{currentJob && (
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span
className={`text-sm font-medium ${getStatusColor(currentJob.status)}`}
>
{currentJob.status === JobStatus.SUCCESS && '✓ '}
{currentJob.status === JobStatus.FAILURE && '✗ '}
{currentJob.status.charAt(0).toUpperCase() +
currentJob.status.slice(1)}
</span>
<span className="text-sm text-gray-600">
{currentJob.progress}%
</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: `${currentJob.progress}%` }}
/>
</div>
</div>
{jobProgress && (
<div className="text-sm text-gray-600">
{jobProgress.processed !== undefined &&
jobProgress.total !== undefined && (
<p>
Processed: {jobProgress.processed} /{' '}
{jobProgress.total}
</p>
)}
{jobProgress.message && (
<p className="mt-1">{jobProgress.message}</p>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Results Section */}
{importResult && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Import Results
</h2>
<div className="space-y-2 text-sm">
{importResult.added !== undefined && (
<p className="text-green-600">
{importResult.added} new photos added
</p>
)}
{importResult.existing !== undefined && (
<p className="text-gray-600">
{importResult.existing} photos already in database
</p>
)}
{importResult.total !== undefined && (
<p className="text-gray-700 font-medium">
Total: {importResult.total} photos
</p>
)}
</div>
</div>
)}
{/* Error Section */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
</div>
</div>
)
}