migration to web
This commit is contained in:
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user