Initial commit — PlantGuideScraper project
This commit is contained in:
14
frontend/Dockerfile
Normal file
14
frontend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
283
frontend/dist/assets/index-BXIq8BNP.js
vendored
Normal file
283
frontend/dist/assets/index-BXIq8BNP.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-uHzGA3u6.css
vendored
Normal file
1
frontend/dist/assets/index-uHzGA3u6.css
vendored
Normal file
File diff suppressed because one or more lines are too long
14
frontend/dist/index.html
vendored
Normal file
14
frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PlantGuideScraper</title>
|
||||
<script type="module" crossorigin src="/assets/index-BXIq8BNP.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-uHzGA3u6.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PlantGuideScraper</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "plant-scraper-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"axios": "^1.6.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
"recharts": "^2.10.0",
|
||||
"clsx": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
81
frontend/src/App.tsx
Normal file
81
frontend/src/App.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Leaf,
|
||||
Image,
|
||||
Play,
|
||||
Download,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Species from './pages/Species'
|
||||
import Images from './pages/Images'
|
||||
import Jobs from './pages/Jobs'
|
||||
import Export from './pages/Export'
|
||||
import SettingsPage from './pages/Settings'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/species', icon: Leaf, label: 'Species' },
|
||||
{ to: '/images', icon: Image, label: 'Images' },
|
||||
{ to: '/jobs', icon: Play, label: 'Jobs' },
|
||||
{ to: '/export', icon: Download, label: 'Export' },
|
||||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||||
]
|
||||
|
||||
function Sidebar() {
|
||||
return (
|
||||
<aside className="w-64 bg-white border-r border-gray-200 min-h-screen">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h1 className="text-xl font-bold text-green-600 flex items-center gap-2">
|
||||
<Leaf className="w-6 h-6" />
|
||||
PlantScraper
|
||||
</h1>
|
||||
</div>
|
||||
<nav className="p-4">
|
||||
<ul className="space-y-2">
|
||||
{navItems.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors',
|
||||
isActive
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 p-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/species" element={<Species />} />
|
||||
<Route path="/images" element={<Images />} />
|
||||
<Route path="/jobs" element={<Jobs />} />
|
||||
<Route path="/export" element={<Export />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
275
frontend/src/api/client.ts
Normal file
275
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: `${API_URL}/api`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Types
|
||||
export interface Species {
|
||||
id: number
|
||||
scientific_name: string
|
||||
common_name: string | null
|
||||
genus: string | null
|
||||
family: string | null
|
||||
created_at: string
|
||||
image_count: number
|
||||
}
|
||||
|
||||
export interface SpeciesListResponse {
|
||||
items: Species[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
id: number
|
||||
species_id: number
|
||||
species_name: string | null
|
||||
source: string
|
||||
source_id: string | null
|
||||
url: string
|
||||
local_path: string | null
|
||||
license: string
|
||||
attribution: string | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
quality_score: number | null
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ImageListResponse {
|
||||
items: Image[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: number
|
||||
name: string
|
||||
source: string
|
||||
species_filter: string | null
|
||||
status: string
|
||||
progress_current: number
|
||||
progress_total: number
|
||||
images_downloaded: number
|
||||
images_rejected: number
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface JobListResponse {
|
||||
items: Job[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface JobProgress {
|
||||
status: string
|
||||
progress_current: number
|
||||
progress_total: number
|
||||
current_species?: string
|
||||
}
|
||||
|
||||
export interface Export {
|
||||
id: number
|
||||
name: string
|
||||
filter_criteria: string | null
|
||||
train_split: number
|
||||
status: string
|
||||
file_path: string | null
|
||||
file_size: number | null
|
||||
species_count: number | null
|
||||
image_count: number | null
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
export interface SourceConfig {
|
||||
name: string
|
||||
label: string
|
||||
requires_secret: boolean
|
||||
auth_type: 'none' | 'api_key' | 'api_key_secret' | 'oauth'
|
||||
configured: boolean
|
||||
enabled: boolean
|
||||
api_key_masked: string | null
|
||||
has_secret: boolean
|
||||
has_access_token: boolean
|
||||
rate_limit_per_sec: number
|
||||
default_rate: number
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
total_species: number
|
||||
total_images: number
|
||||
images_downloaded: number
|
||||
images_pending: number
|
||||
images_rejected: number
|
||||
disk_usage_mb: number
|
||||
sources: Array<{
|
||||
source: string
|
||||
image_count: number
|
||||
downloaded: number
|
||||
pending: number
|
||||
rejected: number
|
||||
}>
|
||||
licenses: Array<{
|
||||
license: string
|
||||
count: number
|
||||
}>
|
||||
jobs: {
|
||||
running: number
|
||||
pending: number
|
||||
completed: number
|
||||
failed: number
|
||||
}
|
||||
top_species: Array<{
|
||||
id: number
|
||||
scientific_name: string
|
||||
common_name: string | null
|
||||
image_count: number
|
||||
}>
|
||||
under_represented: Array<{
|
||||
id: number
|
||||
scientific_name: string
|
||||
common_name: string | null
|
||||
image_count: number
|
||||
}>
|
||||
}
|
||||
|
||||
// API functions
|
||||
export const speciesApi = {
|
||||
list: (params?: { page?: number; page_size?: number; search?: string; genus?: string; has_images?: boolean; max_images?: number; min_images?: number }) =>
|
||||
api.get<SpeciesListResponse>('/species', { params }),
|
||||
get: (id: number) => api.get<Species>(`/species/${id}`),
|
||||
create: (data: { scientific_name: string; common_name?: string; genus?: string; family?: string }) =>
|
||||
api.post<Species>('/species', data),
|
||||
update: (id: number, data: Partial<Species>) => api.put<Species>(`/species/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/species/${id}`),
|
||||
import: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post('/species/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
},
|
||||
importJson: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post('/species/import-json', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
},
|
||||
genera: () => api.get<string[]>('/species/genera/list'),
|
||||
}
|
||||
|
||||
export interface ImportScanResult {
|
||||
available: boolean
|
||||
message?: string
|
||||
sources: Array<{
|
||||
name: string
|
||||
species_count: number
|
||||
image_count: number
|
||||
}>
|
||||
total_images: number
|
||||
matched_species: number
|
||||
unmatched_species: string[]
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number
|
||||
skipped: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export const imagesApi = {
|
||||
list: (params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
species_id?: number
|
||||
source?: string
|
||||
license?: string
|
||||
status?: string
|
||||
min_quality?: number
|
||||
search?: string
|
||||
}) => api.get<ImageListResponse>('/images', { params }),
|
||||
get: (id: number) => api.get<Image>(`/images/${id}`),
|
||||
delete: (id: number) => api.delete(`/images/${id}`),
|
||||
bulkDelete: (ids: number[]) => api.post('/images/bulk-delete', ids),
|
||||
sources: () => api.get<string[]>('/images/sources'),
|
||||
licenses: () => api.get<string[]>('/images/licenses'),
|
||||
processPending: (source?: string) =>
|
||||
api.post<{ pending_count: number; task_id: string }>('/images/process-pending', null, {
|
||||
params: source ? { source } : undefined,
|
||||
}),
|
||||
processPendingStatus: (taskId: string) =>
|
||||
api.get<{ task_id: string; state: string; queued?: number; total?: number }>(
|
||||
`/images/process-pending/status/${taskId}`
|
||||
),
|
||||
scanImports: () => api.get<ImportScanResult>('/images/import/scan'),
|
||||
runImport: (moveFiles: boolean = false) =>
|
||||
api.post<ImportResult>('/images/import/run', null, { params: { move_files: moveFiles } }),
|
||||
}
|
||||
|
||||
export const jobsApi = {
|
||||
list: (params?: { status?: string; source?: string; limit?: number }) =>
|
||||
api.get<JobListResponse>('/jobs', { params }),
|
||||
get: (id: number) => api.get<Job>(`/jobs/${id}`),
|
||||
create: (data: { name: string; source: string; species_ids?: number[]; only_without_images?: boolean; max_images?: number }) =>
|
||||
api.post<Job>('/jobs', data),
|
||||
progress: (id: number) => api.get<JobProgress>(`/jobs/${id}/progress`),
|
||||
pause: (id: number) => api.post(`/jobs/${id}/pause`),
|
||||
resume: (id: number) => api.post(`/jobs/${id}/resume`),
|
||||
cancel: (id: number) => api.post(`/jobs/${id}/cancel`),
|
||||
}
|
||||
|
||||
export const exportsApi = {
|
||||
list: (params?: { limit?: number }) => api.get('/exports', { params }),
|
||||
get: (id: number) => api.get<Export>(`/exports/${id}`),
|
||||
create: (data: {
|
||||
name: string
|
||||
filter_criteria: {
|
||||
min_images_per_species: number
|
||||
licenses?: string[]
|
||||
min_quality?: number
|
||||
species_ids?: number[]
|
||||
}
|
||||
train_split: number
|
||||
}) => api.post<Export>('/exports', data),
|
||||
preview: (data: any) => api.post('/exports/preview', data),
|
||||
progress: (id: number) => api.get(`/exports/${id}/progress`),
|
||||
download: (id: number) => `${API_URL}/api/exports/${id}/download`,
|
||||
delete: (id: number) => api.delete(`/exports/${id}`),
|
||||
}
|
||||
|
||||
export const sourcesApi = {
|
||||
list: () => api.get<SourceConfig[]>('/sources'),
|
||||
get: (source: string) => api.get<SourceConfig>(`/sources/${source}`),
|
||||
update: (source: string, data: {
|
||||
api_key?: string
|
||||
api_secret?: string
|
||||
access_token?: string
|
||||
rate_limit_per_sec?: number
|
||||
enabled?: boolean
|
||||
}) => api.put(`/sources/${source}`, { source, ...data }),
|
||||
test: (source: string) => api.post(`/sources/${source}/test`),
|
||||
delete: (source: string) => api.delete(`/sources/${source}`),
|
||||
}
|
||||
|
||||
export const statsApi = {
|
||||
get: () => api.get<Stats>('/stats'),
|
||||
sources: () => api.get('/stats/sources'),
|
||||
species: (params?: { min_count?: number; max_count?: number }) =>
|
||||
api.get('/stats/species', { params }),
|
||||
}
|
||||
7
frontend/src/index.css
Normal file
7
frontend/src/index.css
Normal file
@@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
}
|
||||
22
frontend/src/main.tsx
Normal file
22
frontend/src/main.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
413
frontend/src/pages/Dashboard.tsx
Normal file
413
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Leaf,
|
||||
Image,
|
||||
HardDrive,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from 'recharts'
|
||||
import { statsApi, imagesApi } from '../api/client'
|
||||
|
||||
const COLORS = ['#22c55e', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
}: {
|
||||
title: string
|
||||
value: string | number
|
||||
icon: React.ElementType
|
||||
color: string
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{title}</p>
|
||||
<p className="text-2xl font-bold mt-1">{value}</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-full ${color}`}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [processingTaskId, setProcessingTaskId] = useState<string | null>(null)
|
||||
|
||||
const processPendingMutation = useMutation({
|
||||
mutationFn: () => imagesApi.processPending(),
|
||||
onSuccess: (res) => {
|
||||
setProcessingTaskId(res.data.task_id)
|
||||
},
|
||||
})
|
||||
|
||||
// Poll task status while processing
|
||||
const { data: taskStatus } = useQuery({
|
||||
queryKey: ['process-pending-status', processingTaskId],
|
||||
queryFn: async () => {
|
||||
const res = await imagesApi.processPendingStatus(processingTaskId!)
|
||||
if (res.data.state === 'SUCCESS' || res.data.state === 'FAILURE') {
|
||||
// Task finished - clear tracking and refresh stats
|
||||
setTimeout(() => {
|
||||
setProcessingTaskId(null)
|
||||
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||
}, 0)
|
||||
}
|
||||
return res.data
|
||||
},
|
||||
enabled: !!processingTaskId,
|
||||
refetchInterval: (query) => {
|
||||
const state = query.state.data?.state
|
||||
if (state === 'SUCCESS' || state === 'FAILURE') return false
|
||||
return 2000
|
||||
},
|
||||
})
|
||||
|
||||
const isProcessing = !!processingTaskId && taskStatus?.state !== 'SUCCESS' && taskStatus?.state !== 'FAILURE'
|
||||
|
||||
const { data: stats, isLoading, error, failureCount, isFetching } = useQuery({
|
||||
queryKey: ['stats'],
|
||||
queryFn: async () => {
|
||||
const startTime = Date.now()
|
||||
console.log('[Dashboard] Fetching stats...')
|
||||
|
||||
// Create abort controller for timeout
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout
|
||||
|
||||
try {
|
||||
const res = await statsApi.get()
|
||||
clearTimeout(timeoutId)
|
||||
console.log(`[Dashboard] Stats loaded in ${Date.now() - startTime}ms`)
|
||||
return res.data
|
||||
} catch (err: any) {
|
||||
clearTimeout(timeoutId)
|
||||
if (err.name === 'AbortError' || err.code === 'ECONNABORTED') {
|
||||
console.error('[Dashboard] Request timed out after 10 seconds')
|
||||
throw new Error('Request timed out after 10 seconds - backend may be unresponsive')
|
||||
}
|
||||
console.error('[Dashboard] Stats fetch failed:', err)
|
||||
console.error('[Dashboard] Error details:', {
|
||||
message: err.message,
|
||||
status: err.response?.status,
|
||||
statusText: err.response?.statusText,
|
||||
data: err.response?.data,
|
||||
})
|
||||
throw err
|
||||
}
|
||||
},
|
||||
refetchInterval: 30000, // 30 seconds - matches backend cache
|
||||
retry: 1,
|
||||
staleTime: 25000,
|
||||
})
|
||||
|
||||
// Debug panel to test backend
|
||||
const { data: debugData, refetch: refetchDebug, isFetching: isDebugFetching } = useQuery({
|
||||
queryKey: ['debug'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/debug')
|
||||
return res.json()
|
||||
},
|
||||
enabled: false, // Only fetch when manually triggered
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600 mx-auto"></div>
|
||||
<p className="mt-2 text-gray-500">Loading stats...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const err = error as any
|
||||
return (
|
||||
<div className="space-y-4 m-4">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<h2 className="text-lg font-bold text-red-700 mb-2">Failed to load dashboard</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p><strong>Error:</strong> {err.message}</p>
|
||||
{err.response && (
|
||||
<>
|
||||
<p><strong>Status:</strong> {err.response.status} {err.response.statusText}</p>
|
||||
{err.response.data && (
|
||||
<p><strong>Response:</strong> {JSON.stringify(err.response.data)}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<p><strong>Retry count:</strong> {failureCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="font-bold text-blue-700 mb-2">Debug Backend Connection</h3>
|
||||
<button
|
||||
onClick={() => refetchDebug()}
|
||||
disabled={isDebugFetching}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isDebugFetching ? 'Testing...' : 'Test Backend'}
|
||||
</button>
|
||||
{debugData && (
|
||||
<pre className="mt-4 p-4 bg-white rounded text-xs overflow-auto">
|
||||
{JSON.stringify(debugData, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return <div>Failed to load stats</div>
|
||||
}
|
||||
|
||||
const sourceData = stats.sources.map((s) => ({
|
||||
name: s.source,
|
||||
downloaded: s.downloaded,
|
||||
pending: s.pending,
|
||||
rejected: s.rejected,
|
||||
}))
|
||||
|
||||
const licenseData = stats.licenses.map((l, i) => ({
|
||||
name: l.license,
|
||||
value: l.count,
|
||||
color: COLORS[i % COLORS.length],
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="Total Species"
|
||||
value={stats.total_species.toLocaleString()}
|
||||
icon={Leaf}
|
||||
color="bg-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Downloaded Images"
|
||||
value={stats.images_downloaded.toLocaleString()}
|
||||
icon={Image}
|
||||
color="bg-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Pending Images"
|
||||
value={stats.images_pending.toLocaleString()}
|
||||
icon={Clock}
|
||||
color="bg-yellow-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Disk Usage"
|
||||
value={`${stats.disk_usage_mb.toFixed(1)} MB`}
|
||||
icon={HardDrive}
|
||||
color="bg-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Process Pending Banner */}
|
||||
{(stats.images_pending > 0 || isProcessing) && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-yellow-800">
|
||||
{isProcessing
|
||||
? `Processing pending images...`
|
||||
: `${stats.images_pending.toLocaleString()} pending images`}
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700">
|
||||
{isProcessing && taskStatus?.queued != null && taskStatus?.total != null
|
||||
? `Queued ${taskStatus.queued.toLocaleString()} of ${taskStatus.total.toLocaleString()} for download`
|
||||
: isProcessing
|
||||
? 'Queueing images for download...'
|
||||
: 'These images have been scraped but not yet downloaded and processed.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => processPendingMutation.mutate()}
|
||||
disabled={isProcessing || processPendingMutation.isPending}
|
||||
className="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
{isProcessing ? 'Processing...' : processPendingMutation.isPending ? 'Starting...' : 'Process All Pending'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jobs Status */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Jobs Status</h2>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500 animate-pulse"></div>
|
||||
<span>Running: {stats.jobs.running}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-yellow-500" />
|
||||
<span>Pending: {stats.jobs.pending}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span>Completed: {stats.jobs.completed}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
<span>Failed: {stats.jobs.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Source Chart */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Images by Source</h2>
|
||||
{sourceData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={sourceData}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="downloaded" fill="#22c55e" name="Downloaded" />
|
||||
<Bar dataKey="pending" fill="#f59e0b" name="Pending" />
|
||||
<Bar dataKey="rejected" fill="#ef4444" name="Rejected" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-gray-400">
|
||||
No data yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* License Chart */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Images by License</h2>
|
||||
{licenseData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={licenseData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={100}
|
||||
label={({ name, percent }) =>
|
||||
`${name} (${(percent * 100).toFixed(0)}%)`
|
||||
}
|
||||
>
|
||||
{licenseData.map((entry, index) => (
|
||||
<Cell key={index} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-gray-400">
|
||||
No data yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Species Tables */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Species */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Top Species</h2>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-sm text-gray-500">
|
||||
<th className="pb-2">Species</th>
|
||||
<th className="pb-2 text-right">Images</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.top_species.map((s) => (
|
||||
<tr key={s.id} className="border-t">
|
||||
<td className="py-2">
|
||||
<div className="font-medium">{s.scientific_name}</div>
|
||||
{s.common_name && (
|
||||
<div className="text-sm text-gray-500">{s.common_name}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">{s.image_count}</td>
|
||||
</tr>
|
||||
))}
|
||||
{stats.top_species.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={2} className="py-4 text-center text-gray-400">
|
||||
No species yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Under-represented Species */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
Under-represented Species
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">Species with fewer than 100 images</p>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-sm text-gray-500">
|
||||
<th className="pb-2">Species</th>
|
||||
<th className="pb-2 text-right">Images</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.under_represented.map((s) => (
|
||||
<tr key={s.id} className="border-t">
|
||||
<td className="py-2">
|
||||
<div className="font-medium">{s.scientific_name}</div>
|
||||
{s.common_name && (
|
||||
<div className="text-sm text-gray-500">{s.common_name}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right text-yellow-600">{s.image_count}</td>
|
||||
</tr>
|
||||
))}
|
||||
{stats.under_represented.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={2} className="py-4 text-center text-gray-400">
|
||||
All species have 100+ images
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
346
frontend/src/pages/Export.tsx
Normal file
346
frontend/src/pages/Export.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Package,
|
||||
} from 'lucide-react'
|
||||
import { exportsApi, imagesApi, Export as ExportType } from '../api/client'
|
||||
|
||||
export default function Export() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
const { data: exports, isLoading } = useQuery({
|
||||
queryKey: ['exports'],
|
||||
queryFn: () => exportsApi.list({ limit: 50 }).then((res) => res.data),
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => exportsApi.delete(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['exports'] }),
|
||||
})
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'generating':
|
||||
return <Clock className="w-4 h-4 text-blue-500 animate-pulse" />
|
||||
case 'completed':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />
|
||||
case 'failed':
|
||||
return <AlertCircle className="w-4 h-4 text-red-500" />
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-gray-400" />
|
||||
}
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number | null) => {
|
||||
if (!bytes) return 'N/A'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Export Dataset</h1>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
<Package className="w-4 h-4" />
|
||||
Create Export
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-blue-800">Export Format</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Exports are created in Create ML-compatible format with Training and Testing
|
||||
folders. Each species has its own subfolder with images.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Exports List */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
|
||||
</div>
|
||||
) : exports?.items.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-400">
|
||||
<Package className="w-12 h-12 mx-auto mb-4" />
|
||||
<p>No exports yet</p>
|
||||
<p className="text-sm mt-2">
|
||||
Create an export to download your dataset for CoreML training
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{exports?.items.map((exp: ExportType) => (
|
||||
<div
|
||||
key={exp.id}
|
||||
className="bg-white rounded-lg shadow p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(exp.status)}
|
||||
<h3 className="font-semibold">{exp.name}</h3>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Species:</span>{' '}
|
||||
{exp.species_count ?? 'N/A'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Images:</span>{' '}
|
||||
{exp.image_count ?? 'N/A'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Size:</span>{' '}
|
||||
{formatBytes(exp.file_size)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Split:</span>{' '}
|
||||
{Math.round(exp.train_split * 100)}% / {Math.round((1 - exp.train_split) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
{exp.error_message && (
|
||||
<div className="mt-2 text-sm text-red-600">
|
||||
Error: {exp.error_message}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
Created: {new Date(exp.created_at).toLocaleString()}
|
||||
{exp.completed_at && (
|
||||
<span className="ml-4">
|
||||
Completed: {new Date(exp.completed_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
{exp.status === 'completed' && (
|
||||
<a
|
||||
href={exportsApi.download(exp.id)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(exp.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateExportModal onClose={() => setShowCreateModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateExportModal({ onClose }: { onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [form, setForm] = useState({
|
||||
name: `Export ${new Date().toLocaleDateString()}`,
|
||||
min_images: 100,
|
||||
train_split: 0.8,
|
||||
licenses: [] as string[],
|
||||
min_quality: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { data: licenses } = useQuery({
|
||||
queryKey: ['image-licenses'],
|
||||
queryFn: () => imagesApi.licenses().then((res) => res.data),
|
||||
})
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
exportsApi.preview({
|
||||
name: form.name,
|
||||
filter_criteria: {
|
||||
min_images_per_species: form.min_images,
|
||||
licenses: form.licenses.length > 0 ? form.licenses : undefined,
|
||||
min_quality: form.min_quality,
|
||||
},
|
||||
train_split: form.train_split,
|
||||
}),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
exportsApi.create({
|
||||
name: form.name,
|
||||
filter_criteria: {
|
||||
min_images_per_species: form.min_images,
|
||||
licenses: form.licenses.length > 0 ? form.licenses : undefined,
|
||||
min_quality: form.min_quality,
|
||||
},
|
||||
train_split: form.train_split,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['exports'] })
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
const toggleLicense = (license: string) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
licenses: f.licenses.includes(license)
|
||||
? f.licenses.filter((l) => l !== license)
|
||||
: [...f.licenses, license],
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-lg">
|
||||
<h2 className="text-xl font-bold mb-4">Create Export</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Export Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Minimum Images per Species
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.min_images}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, min_images: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
min={1}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Species with fewer images will be excluded
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Train/Test Split
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
value={form.train_split}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, train_split: parseFloat(e.target.value) })
|
||||
}
|
||||
min={0.5}
|
||||
max={0.95}
|
||||
step={0.05}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm w-20 text-right">
|
||||
{Math.round(form.train_split * 100)}% /{' '}
|
||||
{Math.round((1 - form.train_split) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Filter by License (optional)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{licenses?.map((license) => (
|
||||
<button
|
||||
key={license}
|
||||
onClick={() => toggleLicense(license)}
|
||||
className={`px-3 py-1 rounded-full text-sm ${
|
||||
form.licenses.includes(license)
|
||||
? 'bg-green-100 text-green-700 border-green-300'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
} border`}
|
||||
>
|
||||
{license}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{form.licenses.length === 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
All licenses will be included
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{previewMutation.data && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-medium mb-2">Preview</h4>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Species:</span>{' '}
|
||||
{previewMutation.data.data.species_count}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Images:</span>{' '}
|
||||
{previewMutation.data.data.image_count}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Est. Size:</span>{' '}
|
||||
{previewMutation.data.data.estimated_size_mb.toFixed(0)} MB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<button
|
||||
onClick={() => previewMutation.mutate()}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!form.name}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
Create Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
331
frontend/src/pages/Images.tsx
Normal file
331
frontend/src/pages/Images.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Trash2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { imagesApi } from '../api/client'
|
||||
|
||||
export default function Images() {
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [filters, setFilters] = useState({
|
||||
source: '',
|
||||
license: '',
|
||||
status: 'downloaded',
|
||||
min_quality: undefined as number | undefined,
|
||||
})
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
const [selectedImage, setSelectedImage] = useState<number | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['images', page, search, filters],
|
||||
queryFn: () =>
|
||||
imagesApi
|
||||
.list({
|
||||
page,
|
||||
page_size: 48,
|
||||
search: search || undefined,
|
||||
source: filters.source || undefined,
|
||||
license: filters.license || undefined,
|
||||
status: filters.status || undefined,
|
||||
min_quality: filters.min_quality,
|
||||
})
|
||||
.then((res) => res.data),
|
||||
})
|
||||
|
||||
const { data: sources } = useQuery({
|
||||
queryKey: ['image-sources'],
|
||||
queryFn: () => imagesApi.sources().then((res) => res.data),
|
||||
})
|
||||
|
||||
const { data: licenses } = useQuery({
|
||||
queryKey: ['image-licenses'],
|
||||
queryFn: () => imagesApi.licenses().then((res) => res.data),
|
||||
})
|
||||
|
||||
const { data: imageDetail } = useQuery({
|
||||
queryKey: ['image', selectedImage],
|
||||
queryFn: () => imagesApi.get(selectedImage!).then((res) => res.data),
|
||||
enabled: !!selectedImage,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => imagesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['images'] })
|
||||
setSelectedImage(null)
|
||||
},
|
||||
})
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: (ids: number[]) => imagesApi.bulkDelete(ids),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['images'] })
|
||||
setSelectedIds([])
|
||||
},
|
||||
})
|
||||
|
||||
const handleSelect = (id: number) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Images</h1>
|
||||
{selectedIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => bulkDeleteMutation.mutate(selectedIds)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete {selectedIds.length} images
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search species..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="pl-10 pr-4 py-2 border rounded-lg w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filters.source}
|
||||
onChange={(e) => setFilters({ ...filters, source: e.target.value })}
|
||||
className="px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="">All Sources</option>
|
||||
{sources?.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.license}
|
||||
onChange={(e) => setFilters({ ...filters, license: e.target.value })}
|
||||
className="px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="">All Licenses</option>
|
||||
{licenses?.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="downloaded">Downloaded</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Image Grid */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
|
||||
</div>
|
||||
) : data?.items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-gray-400">
|
||||
<Filter className="w-12 h-12 mb-4" />
|
||||
<p>No images found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
|
||||
{data?.items.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className={`relative aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer group ${
|
||||
selectedIds.includes(image.id) ? 'ring-2 ring-green-500' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedImage(image.id)}
|
||||
>
|
||||
{image.local_path ? (
|
||||
<img
|
||||
src={`/api/images/${image.id}/file`}
|
||||
alt={image.species_name || ''}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400 text-xs">
|
||||
Pending
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
<div className="absolute top-1 left-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(image.id)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSelect(image.id)
|
||||
}}
|
||||
className="rounded opacity-0 group-hover:opacity-100 checked:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-white text-xs truncate">
|
||||
{image.species_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.pages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">
|
||||
{data.total} images
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-2 rounded border disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="px-4 py-2">
|
||||
Page {page} of {data.pages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
|
||||
disabled={page === data.pages}
|
||||
className="p-2 rounded border disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Detail Modal */}
|
||||
{selectedImage && imageDetail && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-8">
|
||||
<div className="bg-white rounded-lg w-full max-w-4xl max-h-full overflow-auto">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">Image Details</h2>
|
||||
<button
|
||||
onClick={() => setSelectedImage(null)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6 p-6">
|
||||
<div className="aspect-square bg-gray-100 rounded-lg overflow-hidden">
|
||||
{imageDetail.local_path ? (
|
||||
<img
|
||||
src={`/api/images/${imageDetail.id}/file`}
|
||||
alt={imageDetail.species_name || ''}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
Not downloaded
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Species</label>
|
||||
<p className="font-medium">{imageDetail.species_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Source</label>
|
||||
<p>{imageDetail.source}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">License</label>
|
||||
<p>{imageDetail.license}</p>
|
||||
</div>
|
||||
{imageDetail.attribution && (
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Attribution</label>
|
||||
<p className="text-sm">{imageDetail.attribution}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Dimensions</label>
|
||||
<p>
|
||||
{imageDetail.width || '?'} x {imageDetail.height || '?'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Quality Score</label>
|
||||
<p>{imageDetail.quality_score?.toFixed(1) || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Status</label>
|
||||
<p>
|
||||
<span
|
||||
className={`inline-block px-2 py-1 rounded text-sm ${
|
||||
imageDetail.status === 'downloaded'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: imageDetail.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{imageDetail.status}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<a
|
||||
href={imageDetail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
View Original
|
||||
</a>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(imageDetail.id)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
354
frontend/src/pages/Jobs.tsx
Normal file
354
frontend/src/pages/Jobs.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
XCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Leaf,
|
||||
Download,
|
||||
XOctagon,
|
||||
} from 'lucide-react'
|
||||
import { jobsApi, Job } from '../api/client'
|
||||
|
||||
export default function Jobs() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['jobs'],
|
||||
queryFn: () => jobsApi.list({ limit: 100 }).then((res) => res.data),
|
||||
refetchInterval: 1000, // Faster refresh for live updates
|
||||
})
|
||||
|
||||
const pauseMutation = useMutation({
|
||||
mutationFn: (id: number) => jobsApi.pause(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
|
||||
})
|
||||
|
||||
const resumeMutation = useMutation({
|
||||
mutationFn: (id: number) => jobsApi.resume(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
|
||||
})
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (id: number) => jobsApi.cancel(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
|
||||
})
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <RefreshCw className="w-4 h-4 text-blue-500 animate-spin" />
|
||||
case 'pending':
|
||||
return <Clock className="w-4 h-4 text-yellow-500" />
|
||||
case 'paused':
|
||||
return <Pause className="w-4 h-4 text-gray-500" />
|
||||
case 'completed':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />
|
||||
case 'failed':
|
||||
return <AlertCircle className="w-4 h-4 text-red-500" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'bg-blue-100 text-blue-700'
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-700'
|
||||
case 'paused':
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-700'
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-700'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
// Separate running jobs from others
|
||||
const runningJobs = data?.items.filter((j) => j.status === 'running') || []
|
||||
const otherJobs = data?.items.filter((j) => j.status !== 'running') || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Jobs</h1>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="flex items-center gap-2 px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
|
||||
</div>
|
||||
) : data?.items.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-400">
|
||||
<Clock className="w-12 h-12 mx-auto mb-4" />
|
||||
<p>No jobs yet</p>
|
||||
<p className="text-sm mt-2">
|
||||
Select species and start a scrape job to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Running Jobs - More prominent display */}
|
||||
{runningJobs.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 animate-spin text-blue-500" />
|
||||
Active Jobs ({runningJobs.length})
|
||||
</h2>
|
||||
{runningJobs.map((job) => (
|
||||
<RunningJobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
onPause={() => pauseMutation.mutate(job.id)}
|
||||
onCancel={() => cancelMutation.mutate(job.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Jobs */}
|
||||
{otherJobs.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{runningJobs.length > 0 && (
|
||||
<h2 className="text-lg font-semibold text-gray-600">Other Jobs</h2>
|
||||
)}
|
||||
{otherJobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="bg-white rounded-lg shadow p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(job.status)}
|
||||
<h3 className="font-semibold">{job.name}</h3>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${getStatusClass(
|
||||
job.status
|
||||
)}`}
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<span className="mr-4">Source: {job.source}</span>
|
||||
<span className="mr-4">
|
||||
Downloaded: {job.images_downloaded}
|
||||
</span>
|
||||
<span>Rejected: {job.images_rejected}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar for paused jobs */}
|
||||
{job.status === 'paused' && job.progress_total > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>
|
||||
{job.progress_current} / {job.progress_total} species
|
||||
</span>
|
||||
<span>
|
||||
{Math.round(
|
||||
(job.progress_current / job.progress_total) * 100
|
||||
)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gray-400"
|
||||
style={{
|
||||
width: `${
|
||||
(job.progress_current / job.progress_total) * 100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.error_message && (
|
||||
<div className="mt-2 text-sm text-red-600">
|
||||
Error: {job.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
{job.started_at && (
|
||||
<span className="mr-4">
|
||||
Started: {new Date(job.started_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{job.completed_at && (
|
||||
<span>
|
||||
Completed: {new Date(job.completed_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 ml-4">
|
||||
{job.status === 'paused' && (
|
||||
<button
|
||||
onClick={() => resumeMutation.mutate(job.id)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded"
|
||||
title="Resume"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{(job.status === 'paused' || job.status === 'pending') && (
|
||||
<button
|
||||
onClick={() => cancelMutation.mutate(job.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded"
|
||||
title="Cancel"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RunningJobCard({
|
||||
job,
|
||||
onPause,
|
||||
onCancel,
|
||||
}: {
|
||||
job: Job
|
||||
onPause: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
// Fetch real-time progress for this job
|
||||
const { data: progress } = useQuery({
|
||||
queryKey: ['job-progress', job.id],
|
||||
queryFn: () => jobsApi.progress(job.id).then((res) => res.data),
|
||||
refetchInterval: 500, // Very fast updates for live feel
|
||||
enabled: job.status === 'running',
|
||||
})
|
||||
|
||||
const currentSpecies = progress?.current_species || ''
|
||||
const progressCurrent = progress?.progress_current ?? job.progress_current
|
||||
const progressTotal = progress?.progress_total ?? job.progress_total
|
||||
const percentage = progressTotal > 0 ? Math.round((progressCurrent / progressTotal) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-blue-50 to-white rounded-lg shadow-lg border-2 border-blue-200 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<RefreshCw className="w-5 h-5 text-blue-500 animate-spin" />
|
||||
<h3 className="font-semibold text-lg">{job.name}</h3>
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-700 animate-pulse">
|
||||
running
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Live Stats */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg p-3 border">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm">
|
||||
<Leaf className="w-4 h-4" />
|
||||
Species Progress
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600 mt-1">
|
||||
{progressCurrent} / {progressTotal}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm">
|
||||
<Download className="w-4 h-4" />
|
||||
Downloaded
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-600 mt-1">
|
||||
{job.images_downloaded}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm">
|
||||
<XOctagon className="w-4 h-4" />
|
||||
Rejected
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-red-600 mt-1">
|
||||
{job.images_rejected}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Species */}
|
||||
{currentSpecies && (
|
||||
<div className="mt-4 bg-white rounded-lg p-3 border">
|
||||
<div className="text-sm text-gray-500 mb-1">Currently scraping:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-blue-500"></span>
|
||||
</span>
|
||||
<span className="font-medium text-blue-800 italic">{currentSpecies}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{progressTotal > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>Progress</span>
|
||||
<span className="font-medium">{percentage}%</span>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-500"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
Source: {job.source} • Started: {job.started_at ? new Date(job.started_at).toLocaleString() : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
onClick={onPause}
|
||||
className="p-2 text-gray-600 hover:bg-gray-100 rounded"
|
||||
title="Pause"
|
||||
>
|
||||
<Pause className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded"
|
||||
title="Cancel"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
543
frontend/src/pages/Settings.tsx
Normal file
543
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Key,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RefreshCw,
|
||||
FolderInput,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { sourcesApi, imagesApi, SourceConfig, ImportScanResult } from '../api/client'
|
||||
|
||||
export default function Settings() {
|
||||
const [editingSource, setEditingSource] = useState<string | null>(null)
|
||||
|
||||
const { data: sources, isLoading, error } = useQuery({
|
||||
queryKey: ['sources'],
|
||||
queryFn: () => sourcesApi.list().then((res) => res.data),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
|
||||
{/* API Keys Section */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
API Keys
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Configure API keys for each data source
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-gray-400" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6 text-center text-red-600">
|
||||
Error loading sources: {(error as Error).message}
|
||||
</div>
|
||||
) : !sources || sources.length === 0 ? (
|
||||
<div className="p-6 text-center text-gray-500">
|
||||
No sources available
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{sources.map((source) => (
|
||||
<SourceRow
|
||||
key={source.name}
|
||||
source={source}
|
||||
isEditing={editingSource === source.name}
|
||||
onEdit={() => setEditingSource(source.name)}
|
||||
onClose={() => setEditingSource(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Import Scanner Section */}
|
||||
<ImportScanner />
|
||||
|
||||
{/* Rate Limits Info */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-yellow-800">Rate Limits (recommended settings)</h3>
|
||||
<ul className="text-sm text-yellow-700 mt-2 space-y-1 list-disc list-inside">
|
||||
<li>GBIF: 1 req/sec safe (free, no authentication required)</li>
|
||||
<li>iNaturalist: 1 req/sec max (60/min limit), 10k/day, 5GB/hr media</li>
|
||||
<li>Flickr: 0.5 req/sec recommended (3600/hr limit shared across all users)</li>
|
||||
<li>Wikimedia: 1 req/sec safe (requires OAuth credentials)</li>
|
||||
<li>Trefle: 1 req/sec safe (120/min limit)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceRow({
|
||||
source,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onClose,
|
||||
}: {
|
||||
source: SourceConfig
|
||||
isEditing: boolean
|
||||
onEdit: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
api_key: '',
|
||||
api_secret: '',
|
||||
access_token: '',
|
||||
rate_limit_per_sec: source.configured ? source.rate_limit_per_sec : (source.default_rate || 1.0),
|
||||
enabled: source.enabled,
|
||||
})
|
||||
|
||||
// Get field labels based on auth type
|
||||
const isNoAuth = source.auth_type === 'none'
|
||||
const isOAuth = source.auth_type === 'oauth'
|
||||
const keyLabel = isOAuth ? 'Client ID' : 'API Key'
|
||||
const secretLabel = isOAuth ? 'Client Secret' : 'API Secret'
|
||||
const [testResult, setTestResult] = useState<{
|
||||
status: 'success' | 'error'
|
||||
message: string
|
||||
} | null>(null)
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
sourcesApi.update(source.name, {
|
||||
api_key: isNoAuth ? undefined : form.api_key || undefined,
|
||||
api_secret: form.api_secret || undefined,
|
||||
access_token: form.access_token || undefined,
|
||||
rate_limit_per_sec: form.rate_limit_per_sec,
|
||||
enabled: form.enabled,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sources'] })
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => sourcesApi.test(source.name),
|
||||
onSuccess: (res) => {
|
||||
setTestResult({ status: res.data.status, message: res.data.message })
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setTestResult({
|
||||
status: 'error',
|
||||
message: err.response?.data?.message || 'Connection failed',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium">{source.label}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isNoAuth ? (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3 text-green-700 text-sm">
|
||||
This source doesn't require authentication. Just enable it to start scraping.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{keyLabel}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={form.api_key}
|
||||
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||
placeholder={source.api_key_masked || `Enter ${keyLabel}`}
|
||||
className="w-full px-3 py-2 border rounded-lg pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
>
|
||||
{showKey ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{source.requires_secret && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{secretLabel}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.api_secret}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, api_secret: e.target.value })
|
||||
}
|
||||
placeholder={source.has_secret ? '••••••••' : `Enter ${secretLabel}`}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOAuth && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Access Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.access_token}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, access_token: e.target.value })
|
||||
}
|
||||
placeholder={source.has_access_token ? '••••••••' : 'Enter Access Token'}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Rate Limit (requests/sec)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.rate_limit_per_sec}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
rate_limit_per_sec: parseFloat(e.target.value) || 1,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="enabled" className="text-sm">
|
||||
Enable this source
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
className={`p-3 rounded-lg ${
|
||||
testResult.status === 'success'
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'bg-red-50 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
{source.configured && (
|
||||
<button
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={testMutation.isPending}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-white"
|
||||
>
|
||||
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => updateMutation.mutate()}
|
||||
disabled={!isNoAuth && !form.api_key && !source.configured}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 ml-auto"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isNoAuthRow = source.auth_type === 'none'
|
||||
|
||||
return (
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
(isNoAuthRow || source.configured) && source.enabled
|
||||
? 'bg-green-500'
|
||||
: source.configured
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-medium">{source.label}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{isNoAuthRow
|
||||
? 'No authentication required'
|
||||
: source.configured
|
||||
? `Key: ${source.api_key_masked}`
|
||||
: 'Not configured'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{(isNoAuthRow || source.configured) && (
|
||||
<span
|
||||
className={`flex items-center gap-1 text-sm ${
|
||||
source.enabled ? 'text-green-600' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{source.enabled ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Enabled
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-4 h-4" />
|
||||
Disabled
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50"
|
||||
>
|
||||
{isNoAuthRow || source.configured ? 'Edit' : 'Configure'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportScanner() {
|
||||
const [scanResult, setScanResult] = useState<ImportScanResult | null>(null)
|
||||
const [moveFiles, setMoveFiles] = useState(false)
|
||||
const [importResult, setImportResult] = useState<{
|
||||
imported: number
|
||||
skipped: number
|
||||
errors: string[]
|
||||
} | null>(null)
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: () => imagesApi.scanImports().then((res) => res.data),
|
||||
onSuccess: (data) => {
|
||||
setScanResult(data)
|
||||
setImportResult(null)
|
||||
},
|
||||
})
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: () => imagesApi.runImport(moveFiles).then((res) => res.data),
|
||||
onSuccess: (data) => {
|
||||
setImportResult(data)
|
||||
setScanResult(null)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<FolderInput className="w-5 h-5" />
|
||||
Import Images
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Bulk import images from the imports folder
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-medium text-sm mb-2">Expected folder structure:</h3>
|
||||
<code className="text-sm text-gray-600 block">
|
||||
imports/{'{source}'}/{'{species_name}'}/*.jpg
|
||||
</code>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Example: imports/inaturalist/Monstera_deliciosa/image1.jpg
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => scanMutation.mutate()}
|
||||
disabled={scanMutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{scanMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Scanning...
|
||||
</>
|
||||
) : (
|
||||
'Scan Imports Folder'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scanMutation.isError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
Error scanning: {(scanMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scanResult && (
|
||||
<div className="space-y-4">
|
||||
{!scanResult.available ? (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p className="text-yellow-700">{scanResult.message}</p>
|
||||
</div>
|
||||
) : scanResult.total_images === 0 ? (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<p className="text-gray-600">No images found in the imports folder.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-green-800 mb-2">Scan Results</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600">Total Images:</span>
|
||||
<span className="ml-2 font-medium">{scanResult.total_images}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Matched Species:</span>
|
||||
<span className="ml-2 font-medium">{scanResult.matched_species}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scanResult.sources.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-green-800 mb-2">Sources Found:</h4>
|
||||
<div className="space-y-1">
|
||||
{scanResult.sources.map((source) => (
|
||||
<div key={source.name} className="text-sm flex justify-between">
|
||||
<span>{source.name}</span>
|
||||
<span className="text-gray-600">
|
||||
{source.species_count} species, {source.image_count} images
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scanResult.unmatched_species.length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-yellow-800 flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Unmatched Species ({scanResult.unmatched_species.length})
|
||||
</h3>
|
||||
<p className="text-sm text-yellow-700 mb-2">
|
||||
These species folders don't match any species in the database and will be skipped:
|
||||
</p>
|
||||
<div className="text-sm text-yellow-600 max-h-32 overflow-y-auto">
|
||||
{scanResult.unmatched_species.slice(0, 20).map((name) => (
|
||||
<div key={name}>{name}</div>
|
||||
))}
|
||||
{scanResult.unmatched_species.length > 20 && (
|
||||
<div className="text-yellow-500 mt-1">
|
||||
...and {scanResult.unmatched_species.length - 20} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={moveFiles}
|
||||
onChange={(e) => setMoveFiles(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Move files instead of copy (removes originals)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => importMutation.mutate()}
|
||||
disabled={importMutation.isPending || scanResult.matched_species === 0}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{importMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
`Import ${scanResult.total_images} Images`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-green-800 mb-2">Import Complete</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>
|
||||
<span className="text-gray-600">Imported:</span>
|
||||
<span className="ml-2 font-medium text-green-700">{importResult.imported}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Skipped (already exists):</span>
|
||||
<span className="ml-2 font-medium">{importResult.skipped}</span>
|
||||
</div>
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<span className="text-red-600">Errors ({importResult.errors.length}):</span>
|
||||
<div className="text-red-500 mt-1 max-h-24 overflow-y-auto">
|
||||
{importResult.errors.map((err, i) => (
|
||||
<div key={i} className="text-xs">{err}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
997
frontend/src/pages/Species.tsx
Normal file
997
frontend/src/pages/Species.tsx
Normal file
@@ -0,0 +1,997 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Plus,
|
||||
Upload,
|
||||
Search,
|
||||
Trash2,
|
||||
Play,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
X,
|
||||
Image as ImageIcon,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { speciesApi, jobsApi, imagesApi, Species as SpeciesType } from '../api/client'
|
||||
|
||||
export default function Species() {
|
||||
const queryClient = useQueryClient()
|
||||
const csvInputRef = useRef<HTMLInputElement>(null)
|
||||
const jsonInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [genus, setGenus] = useState<string>('')
|
||||
const [hasImages, setHasImages] = useState<string>('')
|
||||
const [maxImages, setMaxImages] = useState<string>('')
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [showScrapeModal, setShowScrapeModal] = useState(false)
|
||||
const [showScrapeAllModal, setShowScrapeAllModal] = useState(false)
|
||||
const [showScrapeFilteredModal, setShowScrapeFilteredModal] = useState(false)
|
||||
const [viewSpecies, setViewSpecies] = useState<SpeciesType | null>(null)
|
||||
|
||||
const { data: genera } = useQuery({
|
||||
queryKey: ['genera'],
|
||||
queryFn: () => speciesApi.genera().then((res) => res.data),
|
||||
})
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['species', page, search, genus, hasImages, maxImages],
|
||||
queryFn: () =>
|
||||
speciesApi.list({
|
||||
page,
|
||||
page_size: 50,
|
||||
search: search || undefined,
|
||||
genus: genus || undefined,
|
||||
has_images: hasImages === '' ? undefined : hasImages === 'true',
|
||||
max_images: maxImages ? parseInt(maxImages) : undefined,
|
||||
}).then((res) => res.data),
|
||||
})
|
||||
|
||||
const importCsvMutation = useMutation({
|
||||
mutationFn: (file: File) => speciesApi.import(file),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['species'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['genera'] })
|
||||
alert(`Imported ${res.data.imported} species, skipped ${res.data.skipped}`)
|
||||
},
|
||||
})
|
||||
|
||||
const importJsonMutation = useMutation({
|
||||
mutationFn: (file: File) => speciesApi.importJson(file),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['species'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['genera'] })
|
||||
alert(`Imported ${res.data.imported} species, skipped ${res.data.skipped}`)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => speciesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['species'] })
|
||||
},
|
||||
})
|
||||
|
||||
const createJobMutation = useMutation({
|
||||
mutationFn: (data: { name: string; source: string; species_ids?: number[] }) =>
|
||||
jobsApi.create(data),
|
||||
onSuccess: () => {
|
||||
setShowScrapeModal(false)
|
||||
setSelectedIds([])
|
||||
alert('Scrape job created!')
|
||||
},
|
||||
})
|
||||
|
||||
const handleCsvImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
importCsvMutation.mutate(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleJsonImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
importJsonMutation.mutate(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (!data) return
|
||||
if (selectedIds.length === data.items.length) {
|
||||
setSelectedIds([])
|
||||
} else {
|
||||
setSelectedIds(data.items.map((s) => s.id))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (id: number) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Species</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => csvInputRef.current?.click()}
|
||||
disabled={importCsvMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{importCsvMutation.isPending ? 'Importing...' : 'Import CSV'}
|
||||
</button>
|
||||
<input
|
||||
ref={csvInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleCsvImport}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => jsonInputRef.current?.click()}
|
||||
disabled={importJsonMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{importJsonMutation.isPending ? 'Importing...' : 'Import JSON'}
|
||||
</button>
|
||||
<input
|
||||
ref={jsonInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleJsonImport}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Species
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search species..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="pl-10 pr-4 py-2 border rounded-lg w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={genus}
|
||||
onChange={(e) => {
|
||||
setGenus(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="px-3 py-2 border rounded-lg bg-white"
|
||||
>
|
||||
<option value="">All Genera</option>
|
||||
{genera?.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={hasImages}
|
||||
onChange={(e) => {
|
||||
setHasImages(e.target.value)
|
||||
setMaxImages('')
|
||||
setPage(1)
|
||||
}}
|
||||
className="px-3 py-2 border rounded-lg bg-white"
|
||||
>
|
||||
<option value="">All Species</option>
|
||||
<option value="true">Has Images</option>
|
||||
<option value="false">No Images</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={maxImages}
|
||||
onChange={(e) => {
|
||||
setMaxImages(e.target.value)
|
||||
setHasImages('')
|
||||
setPage(1)
|
||||
}}
|
||||
className="px-3 py-2 border rounded-lg bg-white"
|
||||
>
|
||||
<option value="">Any Image Count</option>
|
||||
<option value="25">Less than 25 images</option>
|
||||
<option value="50">Less than 50 images</option>
|
||||
<option value="100">Less than 100 images</option>
|
||||
<option value="250">Less than 250 images</option>
|
||||
<option value="500">Less than 500 images</option>
|
||||
</select>
|
||||
|
||||
{(genus || hasImages || maxImages) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setGenus('')
|
||||
setHasImages('')
|
||||
setMaxImages('')
|
||||
setPage(1)
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
{maxImages && data && data.total > 0 && (
|
||||
<button
|
||||
onClick={() => setShowScrapeFilteredModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Scrape All {data.total} Filtered
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowScrapeAllModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Scrape All Without Images
|
||||
</button>
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
{selectedIds.length} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowScrapeModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Start Scrape
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(data?.items?.length ?? 0) > 0 && selectedIds.length === (data?.items?.length ?? 0)}
|
||||
onChange={handleSelectAll}
|
||||
className="rounded"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
Scientific Name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
Common Name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
Genus
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium text-gray-600">
|
||||
Images
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium text-gray-600">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : data?.items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||
No species found. Import a CSV to get started.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data?.items.map((species) => (
|
||||
<tr
|
||||
key={species.id}
|
||||
className="border-t hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => setViewSpecies(species)}
|
||||
>
|
||||
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(species.id)}
|
||||
onChange={() => handleSelect(species.id)}
|
||||
className="rounded"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium">{species.scientific_name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{species.common_name || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{species.genus || '-'}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span
|
||||
className={`inline-block px-2 py-1 rounded text-sm ${
|
||||
species.image_count >= 100
|
||||
? 'bg-green-100 text-green-700'
|
||||
: species.image_count > 0
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{species.image_count}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(species.id)}
|
||||
className="p-1 text-red-500 hover:bg-red-50 rounded"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.pages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">
|
||||
Showing {(page - 1) * 50 + 1} to {Math.min(page * 50, data.total)} of{' '}
|
||||
{data.total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-2 rounded border disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="px-4 py-2">
|
||||
Page {page} of {data.pages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
|
||||
disabled={page === data.pages}
|
||||
className="p-2 rounded border disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Species Modal */}
|
||||
{showAddModal && (
|
||||
<AddSpeciesModal onClose={() => setShowAddModal(false)} />
|
||||
)}
|
||||
|
||||
{/* Scrape Modal */}
|
||||
{showScrapeModal && (
|
||||
<ScrapeModal
|
||||
selectedIds={selectedIds}
|
||||
onClose={() => setShowScrapeModal(false)}
|
||||
onSubmit={(source) => {
|
||||
createJobMutation.mutate({
|
||||
name: `Scrape ${selectedIds.length} species from ${source}`,
|
||||
source,
|
||||
species_ids: selectedIds,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Species Detail Modal */}
|
||||
{viewSpecies && (
|
||||
<SpeciesDetailModal
|
||||
species={viewSpecies}
|
||||
onClose={() => setViewSpecies(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scrape All Without Images Modal */}
|
||||
{showScrapeAllModal && (
|
||||
<ScrapeAllModal
|
||||
onClose={() => setShowScrapeAllModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scrape All Filtered Modal */}
|
||||
{showScrapeFilteredModal && (
|
||||
<ScrapeFilteredModal
|
||||
maxImages={parseInt(maxImages)}
|
||||
speciesCount={data?.total ?? 0}
|
||||
onClose={() => setShowScrapeFilteredModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddSpeciesModal({ onClose }: { onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [form, setForm] = useState({
|
||||
scientific_name: '',
|
||||
common_name: '',
|
||||
genus: '',
|
||||
family: '',
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => speciesApi.create(form),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['species'] })
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">Add Species</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Scientific Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.scientific_name}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, scientific_name: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
placeholder="e.g. Monstera deliciosa"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Common Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.common_name}
|
||||
onChange={(e) => setForm({ ...form, common_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
placeholder="e.g. Swiss Cheese Plant"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Genus</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.genus}
|
||||
onChange={(e) => setForm({ ...form, genus: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
placeholder="e.g. Monstera"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Family</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.family}
|
||||
onChange={(e) => setForm({ ...form, family: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
placeholder="e.g. Araceae"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={!form.scientific_name}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
Add Species
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrapeModal({
|
||||
selectedIds,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
selectedIds: number[]
|
||||
onClose: () => void
|
||||
onSubmit: (source: string) => void
|
||||
}) {
|
||||
const [source, setSource] = useState('inaturalist')
|
||||
|
||||
const sources = [
|
||||
{ value: 'gbif', label: 'GBIF' },
|
||||
{ value: 'inaturalist', label: 'iNaturalist' },
|
||||
{ value: 'flickr', label: 'Flickr' },
|
||||
{ value: 'wikimedia', label: 'Wikimedia Commons' },
|
||||
{ value: 'trefle', label: 'Trefle.io' },
|
||||
{ value: 'duckduckgo', label: 'DuckDuckGo' },
|
||||
{ value: 'bing', label: 'Bing Image Search' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">Start Scrape Job</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Scrape images for {selectedIds.length} selected species
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Select Source</label>
|
||||
<div className="space-y-2">
|
||||
{sources.map((s) => (
|
||||
<label
|
||||
key={s.value}
|
||||
className={`flex items-center p-3 border rounded-lg cursor-pointer ${
|
||||
source === s.value ? 'border-green-500 bg-green-50' : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
value={s.value}
|
||||
checked={source === s.value}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
className="mr-3"
|
||||
/>
|
||||
{s.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSubmit(source)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Start Scrape
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SpeciesDetailModal({
|
||||
species,
|
||||
onClose,
|
||||
}: {
|
||||
species: SpeciesType
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 20
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['species-images', species.id, page],
|
||||
queryFn: () =>
|
||||
imagesApi.list({
|
||||
species_id: species.id,
|
||||
status: 'downloaded',
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}).then((res) => res.data),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg w-full max-w-5xl max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{species.scientific_name}</h2>
|
||||
{species.common_name && (
|
||||
<p className="text-gray-600">{species.common_name}</p>
|
||||
)}
|
||||
<div className="flex gap-4 mt-2 text-sm text-gray-500">
|
||||
{species.genus && <span>Genus: {species.genus}</span>}
|
||||
{species.family && <span>Family: {species.family}</span>}
|
||||
<span>{species.image_count} images</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Images Grid */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
|
||||
</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-gray-400">
|
||||
<ImageIcon className="w-12 h-12 mb-4" />
|
||||
<p>No images yet</p>
|
||||
<p className="text-sm mt-2">
|
||||
Start a scrape job to download images for this species
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{data.items.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="group relative aspect-square bg-gray-100 rounded-lg overflow-hidden"
|
||||
>
|
||||
{image.local_path ? (
|
||||
<img
|
||||
src={`/api/images/${image.id}/file`}
|
||||
alt={species.scientific_name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
<ImageIcon className="w-8 h-8" />
|
||||
</div>
|
||||
)}
|
||||
{/* Overlay with info */}
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-2">
|
||||
<div className="text-white text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="bg-white/20 px-1.5 py-0.5 rounded">
|
||||
{image.source}
|
||||
</span>
|
||||
<span className="bg-white/20 px-1.5 py-0.5 rounded">
|
||||
{image.license}
|
||||
</span>
|
||||
</div>
|
||||
{image.width && image.height && (
|
||||
<div className="mt-1 text-white/70">
|
||||
{image.width} × {image.height}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{image.url && (
|
||||
<a
|
||||
href={image.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute top-2 right-2 p-1 bg-white/20 rounded hover:bg-white/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 text-white" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.pages > 1 && (
|
||||
<div className="px-6 py-4 border-t flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">
|
||||
Showing {(page - 1) * pageSize + 1} to{' '}
|
||||
{Math.min(page * pageSize, data.total)} of {data.total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-2 rounded border disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="px-4 py-2">
|
||||
Page {page} of {data.pages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
|
||||
disabled={page === data.pages}
|
||||
className="p-2 rounded border disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrapeAllModal({ onClose }: { onClose: () => void }) {
|
||||
const [selectedSources, setSelectedSources] = useState<string[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Fetch count of species without images
|
||||
const { data: speciesData, isLoading } = useQuery({
|
||||
queryKey: ['species-no-images'],
|
||||
queryFn: () =>
|
||||
speciesApi.list({
|
||||
page: 1,
|
||||
page_size: 1,
|
||||
has_images: false,
|
||||
}).then((res) => res.data),
|
||||
})
|
||||
|
||||
const sources = [
|
||||
{ value: 'gbif', label: 'GBIF', description: 'Free biodiversity database, no API key needed' },
|
||||
{ value: 'inaturalist', label: 'iNaturalist', description: 'Research-grade observations with CC licenses' },
|
||||
{ value: 'wikimedia', label: 'Wikimedia Commons', description: 'Free media repository, requires OAuth' },
|
||||
{ value: 'flickr', label: 'Flickr', description: 'Requires API key, CC-licensed photos' },
|
||||
{ value: 'trefle', label: 'Trefle.io', description: 'Plant database, requires API key' },
|
||||
{ value: 'duckduckgo', label: 'DuckDuckGo', description: 'Web image search, no API key needed' },
|
||||
{ value: 'bing', label: 'Bing Image Search', description: 'Azure Cognitive Services, requires API key' },
|
||||
]
|
||||
|
||||
const toggleSource = (source: string) => {
|
||||
setSelectedSources((prev) =>
|
||||
prev.includes(source)
|
||||
? prev.filter((s) => s !== source)
|
||||
: [...prev, source]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (selectedSources.length === 0) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
// Create a job for each selected source
|
||||
for (const source of selectedSources) {
|
||||
await jobsApi.create({
|
||||
name: `Scrape all species without images from ${source}`,
|
||||
source,
|
||||
only_without_images: true,
|
||||
})
|
||||
}
|
||||
alert(`Created ${selectedSources.length} scrape job(s)!`)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
alert('Failed to create jobs')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const speciesCount = speciesData?.total ?? 0
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-lg">
|
||||
<h2 className="text-xl font-bold mb-2">Scrape All Species Without Images</h2>
|
||||
{isLoading ? (
|
||||
<p className="text-gray-600 mb-4">Loading...</p>
|
||||
) : (
|
||||
<p className="text-gray-600 mb-4">
|
||||
{speciesCount === 0 ? (
|
||||
'All species already have images!'
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-orange-600">{speciesCount}</span> species
|
||||
don't have any images yet. Select sources to scrape from:
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{speciesCount > 0 && (
|
||||
<>
|
||||
<div className="space-y-2 mb-6">
|
||||
{sources.map((s) => (
|
||||
<label
|
||||
key={s.value}
|
||||
className={`flex items-start p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||
selectedSources.includes(s.value)
|
||||
? 'border-orange-500 bg-orange-50'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSources.includes(s.value)}
|
||||
onChange={() => toggleSource(s.value)}
|
||||
className="mt-1 mr-3 rounded"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{s.label}</div>
|
||||
<div className="text-sm text-gray-500">{s.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedSources.length > 1 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4 text-sm text-blue-700">
|
||||
<strong>{selectedSources.length} jobs</strong> will be created and run in parallel,
|
||||
one for each selected source.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{speciesCount > 0 && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={selectedSources.length === 0 || isSubmitting}
|
||||
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Creating Jobs...'
|
||||
: `Start ${selectedSources.length || ''} Scrape Job${selectedSources.length !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrapeFilteredModal({
|
||||
maxImages,
|
||||
speciesCount,
|
||||
onClose,
|
||||
}: {
|
||||
maxImages: number
|
||||
speciesCount: number
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [selectedSources, setSelectedSources] = useState<string[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const sources = [
|
||||
{ value: 'gbif', label: 'GBIF', description: 'Free biodiversity database, no API key needed' },
|
||||
{ value: 'inaturalist', label: 'iNaturalist', description: 'Research-grade observations with CC licenses' },
|
||||
{ value: 'wikimedia', label: 'Wikimedia Commons', description: 'Free media repository, requires OAuth' },
|
||||
{ value: 'flickr', label: 'Flickr', description: 'Requires API key, CC-licensed photos' },
|
||||
{ value: 'trefle', label: 'Trefle.io', description: 'Plant database, requires API key' },
|
||||
{ value: 'duckduckgo', label: 'DuckDuckGo', description: 'Web image search, no API key needed' },
|
||||
{ value: 'bing', label: 'Bing Image Search', description: 'Azure Cognitive Services, requires API key' },
|
||||
]
|
||||
|
||||
const toggleSource = (source: string) => {
|
||||
setSelectedSources((prev) =>
|
||||
prev.includes(source)
|
||||
? prev.filter((s) => s !== source)
|
||||
: [...prev, source]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (selectedSources.length === 0) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
for (const source of selectedSources) {
|
||||
await jobsApi.create({
|
||||
name: `Scrape species with <${maxImages} images from ${source}`,
|
||||
source,
|
||||
max_images: maxImages,
|
||||
})
|
||||
}
|
||||
alert(`Created ${selectedSources.length} scrape job(s)!`)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
alert('Failed to create jobs')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-lg">
|
||||
<h2 className="text-xl font-bold mb-2">Scrape All Filtered Species</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<span className="font-semibold text-purple-600">{speciesCount}</span> species
|
||||
have fewer than <span className="font-semibold">{maxImages}</span> images.
|
||||
Select sources to scrape from:
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 mb-6">
|
||||
{sources.map((s) => (
|
||||
<label
|
||||
key={s.value}
|
||||
className={`flex items-start p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||
selectedSources.includes(s.value)
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSources.includes(s.value)}
|
||||
onChange={() => toggleSource(s.value)}
|
||||
className="mt-1 mr-3 rounded"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{s.label}</div>
|
||||
<div className="text-sm text-gray-500">{s.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedSources.length > 1 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4 text-sm text-blue-700">
|
||||
<strong>{selectedSources.length} jobs</strong> will be created and run in parallel,
|
||||
one for each selected source.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={selectedSources.length === 0 || isSubmitting}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Creating Jobs...'
|
||||
: `Start ${selectedSources.length || ''} Scrape Job${selectedSources.length !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
frontend/src/vite-env.d.ts
vendored
Normal file
9
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
// Disable HMR - not useful in Docker deployments
|
||||
hmr: false,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user