Initial commit — PlantGuideScraper project

This commit is contained in:
Trey T
2026-04-12 09:54:27 -05:00
commit 6926f502c5
87 changed files with 29120 additions and 0 deletions

14
frontend/Dockerfile Normal file
View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

14
frontend/dist/index.html vendored Normal file
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

81
frontend/src/App.tsx Normal file
View 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
View 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
View 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
View 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>,
)

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

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

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

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

View 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
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View 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
View 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" }]
}

View 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
View 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,
},
})