From 52b7fdb1c28e4f941edb92a3e3ac3c718bacbbcc Mon Sep 17 00:00:00 2001 From: Excel Date: Sun, 14 Jun 2026 03:50:12 +0530 Subject: [PATCH] feat: changed api client design --- src/App.tsx | 2 +- src/components/ApiClient/ApiClientLayout.tsx | 146 ++++ src/components/ApiClient/ApiClientPanel.tsx | 56 +- src/components/ApiClient/JobRunner.tsx | 412 +++++------- src/components/ApiClient/ResultsGallery.tsx | 409 +++++++++++ src/components/ApiClient/ServerSettings.tsx | 176 ++++- src/components/ApiClient/TemplateManager.tsx | 673 +++++++++++++------ src/components/TabBar.tsx | 8 +- src/index.css | 6 + 9 files changed, 1337 insertions(+), 551 deletions(-) create mode 100644 src/components/ApiClient/ApiClientLayout.tsx create mode 100644 src/components/ApiClient/ResultsGallery.tsx diff --git a/src/App.tsx b/src/App.tsx index 518557c..5a4e8f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -185,7 +185,7 @@ export default function App() { ) : ( -
+
diff --git a/src/components/ApiClient/ApiClientLayout.tsx b/src/components/ApiClient/ApiClientLayout.tsx new file mode 100644 index 0000000..28e9b75 --- /dev/null +++ b/src/components/ApiClient/ApiClientLayout.tsx @@ -0,0 +1,146 @@ +import { useState, type ReactNode, Component } from "react" +import { useApiStore } from "../../store/useApiStore" +import { ServerSettings } from "./ServerSettings" +import { TemplateManager } from "./TemplateManager" +import { JobRunner } from "./JobRunner" +import { ResultsGallery } from "./ResultsGallery" + +type ApiTab = "server" | "templates" | "jobs" | "results" + +const tabs: { key: ApiTab; label: string; icon: string }[] = [ + { key: "server", label: "Server", icon: "dns" }, + { key: "templates", label: "Templates", icon: "description" }, + { key: "jobs", label: "Jobs", icon: "play_circle" }, + { key: "results", label: "Results", icon: "analytics" }, +] + +interface EBProps { + children: ReactNode + name: string +} + +interface EBState { + hasError: boolean + error: string +} + +class SafeWrap extends Component { + constructor(props: EBProps) { + super(props) + this.state = { hasError: false, error: "" } + } + + static getDerivedStateFromError(error: Error): EBState { + return { hasError: true, error: error?.message || String(error) } + } + + componentDidCatch(error: Error) { + console.error(`[SafeWrap ${this.props.name}]`, error) + } + + render() { + if (this.state.hasError) { + return ( +
+
{this.props.name} crashed
+
{this.state.error}
+
+ ) + } + return this.props.children + } +} + +function Sidebar({ active, onChange }: { active: ApiTab; onChange: (t: ApiTab) => void }) { + const setActiveTab = (tab: ApiTab) => { + if (tab === "jobs" || tab === "results") { + // Auto-activate works in both directions + } + onChange(tab) + } + + return ( + + ) +} + +export function ApiClientLayout() { + const [activeTab, setActiveTab] = useState("server") + const setActiveRunId = useApiStore((s) => s.setActiveRunId) + + const handleChange = (tab: ApiTab) => { + setActiveTab(tab) + if (tab === "results") { + // Keep last run selected if any + const runs = useApiStore.getState().runs + if (runs.length > 0 && !useApiStore.getState().activeRunId) { + setActiveRunId(runs[runs.length - 1].id) + } + } + } + + const content = { + server: , + templates: setActiveTab("jobs")} />, + jobs: setActiveTab("results")} />, + results: setActiveTab("jobs")} />, + }[activeTab] + + return ( +
+ +
+
+ {content} +
+
+
+ ) +} diff --git a/src/components/ApiClient/ApiClientPanel.tsx b/src/components/ApiClient/ApiClientPanel.tsx index de8a686..9a30118 100644 --- a/src/components/ApiClient/ApiClientPanel.tsx +++ b/src/components/ApiClient/ApiClientPanel.tsx @@ -1,57 +1,5 @@ -import { Component, type ReactNode } from "react" -import { ServerSettings } from "./ServerSettings" -import { TemplateManager } from "./TemplateManager" -import { JobRunner } from "./JobRunner" - -interface EBProps { - children: ReactNode - name: string -} - -interface EBState { - hasError: boolean - error: string -} - -class SafeWrap extends Component { - constructor(props: EBProps) { - super(props) - this.state = { hasError: false, error: "" } - } - - static getDerivedStateFromError(error: Error): EBState { - return { hasError: true, error: error?.message || String(error) } - } - - componentDidCatch(error: Error) { - console.error(`[SafeWrap ${this.props.name}]`, error) - } - - render() { - if (this.state.hasError) { - return ( -
-
{this.props.name} crashed
-
{this.state.error}
-
- ) - } - return this.props.children - } -} +import { ApiClientLayout } from "./ApiClientLayout" export function ApiClientPanel() { - return ( -
- - - - - - - - - -
- ) + return } diff --git a/src/components/ApiClient/JobRunner.tsx b/src/components/ApiClient/JobRunner.tsx index b5cf3e7..0b58793 100644 --- a/src/components/ApiClient/JobRunner.tsx +++ b/src/components/ApiClient/JobRunner.tsx @@ -1,6 +1,6 @@ import { useState, useRef } from "react" import { useApiStore } from "../../store/useApiStore" -import { submitJob, pollUntilComplete, getOutputUrl, getJobStatus } from "../../utils/apiClient" +import { submitJob, pollUntilComplete, getJobStatus } from "../../utils/apiClient" import type { JobRun, JobStatusResponse } from "../../types/api" function generateId() { @@ -29,18 +29,20 @@ async function imageFileFromPath(path: string): Promise { return new File([bytes], name, { type: mime }) } -export function JobRunner() { +interface JobRunnerProps { + onViewResults?: () => void +} + +export function JobRunner({ onViewResults }: JobRunnerProps) { const serverUrl = useApiStore((s) => s.serverUrl) const templates = useApiStore((s) => s.templates) - const runs = useApiStore((s) => s.runs) const addRun = useApiStore((s) => s.addRun) const updateRun = useApiStore((s) => s.updateRun) - const activeRunId = useApiStore((s) => s.activeRunId) - const setActiveRunId = useApiStore((s) => s.setActiveRunId) const [selectedTemplateId, setSelectedTemplateId] = useState("") const [scannedPaths, setScannedPaths] = useState([]) const [mode, setMode] = useState<"sync" | "async">("async") + const [outputFormat, setOutputFormat] = useState<"json" | "csv" | "both">("json") const [statusMsg, setStatusMsg] = useState("") const [submitting, setSubmitting] = useState(false) const pollRefs = useRef>({}) @@ -166,7 +168,9 @@ export function JobRunner() { j.job_id === final.job_id ? final : j ), status: final.status === "failed" ? "failed" : "completed", + completedAt: Date.now(), })) + if (final.status === "completed") onViewResults?.() }).catch((err) => { setStatusMsg(`Polling error: ${err.message}`) }) @@ -177,7 +181,9 @@ export function JobRunner() { j.job_id === finalStatus.job_id ? finalStatus : j ), status: finalStatus.status === "failed" ? "failed" : "completed", + completedAt: Date.now(), })) + if (finalStatus.status === "completed") onViewResults?.() } } @@ -189,275 +195,165 @@ export function JobRunner() { } } - const activeRun = runs.find((r) => r.id === activeRunId) || runs[runs.length - 1] || null - return ( -
-
- Run Grading +
+
+ Workflow Runner +

New Grading Job

+

+ Initialize a high-precision optical mark recognition task. Configure your template, ingest scanned documents, and define the processing mode. +

+
-
- - -
- -
- - - {scannedPaths.length > 0 && ( -
    - {scannedPaths.map((p, i) => ( -
  • {p.split(/[/\\]/).pop() || p}
  • +
    + {/* Step 1: Select Template */} +
    +
    +
    +

    01. Select Template

    +

    Choose the structural layout for the OMR processing engine.

    +
    + description +
    +
    + + expand_more +
    +
    -
    - -
    - {(["async", "sync"] as const).map((m) => ( + {/* Step 2: Upload Scanned Sheets */} +
    +
    +
    +

    02. Upload Scanned Sheets

    +

    Ingest high-resolution JPEG, PNG files for batch analysis.

    +
    + upload_file +
    + +
    +
    - ))} -
    -
    - - - - {statusMsg && ( -
    - {statusMsg} -
    - )} -
    - -
    - Runs - {runs.length === 0 && ( -
    - No grading runs yet. -
    - )} - {runs.map((run) => ( - - ))} -
    - - {activeRun && } -
    - ) -} - -function ResultsPanel({ run }: { run: JobRun }) { - const serverUrl = useApiStore((s) => s.serverUrl) - const [expandedJob, setExpandedJob] = useState(null) - - const allCompleted = run.jobs.length > 0 && run.jobs.every((j) => j.status === "completed") - const anyFailed = run.jobs.some((j) => j.status === "failed") - const overallStatus = anyFailed ? "failed" : allCompleted ? "completed" : "running" - - return ( -
    -
    - Results - - {overallStatus.toUpperCase()} - -
    - - {run.jobs.length === 0 && ( -
    - No jobs yet. -
    - )} - - {run.jobs.map((job) => ( - setExpandedJob(expandedJob === job.job_id ? null : job.job_id)} - /> - ))} -
    - ) -} - -function JobResultCard({ - job, - serverUrl, - expanded, - onToggle, -}: { - job: JobStatusResponse - serverUrl: string - expanded: boolean - onToggle: () => void -}) { - const statusColor = - job.status === "completed" - ? "text-success border-success/20 bg-success/5" - : job.status === "failed" - ? "text-error border-error/20 bg-error/5" - : "text-warning border-warning/20 bg-warning/5" - - const resultData = job.result as Record | undefined - const extracted = resultData?.ExtractedData as Record | undefined - - return ( -
    - - - {expanded && ( -
    - {job.error_message && ( -
    - {job.error_message} -
    - )} - - {extracted && Object.keys(extracted).length > 0 && ( -
    - Extracted Answers -
    - {Object.entries(extracted).map(([qNum, rawValue]) => { - const label = isNaN(Number(qNum)) ? qNum : `Q${qNum}` - const answers = formatExtractedValue(rawValue) - return ( -
    - {label} - {answers} -
    - ) - })} -
    -
    - )} - - {job.output_files && job.output_files.length > 0 && ( -
    - Output Files -
    - {job.output_files.map((file) => ( - +

    Selected Files ({scannedPaths.length})

    +
    + {scannedPaths.length === 0 && ( +
    No files selected
    + )} + {scannedPaths.map((p, i) => ( +
    - open_in_new - {file} - + {p.split(/[/\\]/).pop() || p} + +
    ))}
    - )} +
    + - {job.result && !extracted && ( -
    - Raw Result -
    -                {JSON.stringify(job.result, null, 2)}
    -              
    + {/* Step 3: Configuration */} +
    +
    +
    +

    03. Configuration

    +

    Define processing priority and output format.

    - )} + settings_input_component +
    + +
    +
    + Processing Mode +
    + + +
    +
    + +
    + Output Format +
    + {(["json", "csv", "both"] as const).map((fmt) => ( + + ))} +
    +
    +
    +
    + + {/* Final Action */} +
    + +

    + Estimated processing time: ~{Math.max(1, scannedPaths.length * 14)} seconds +

    +
    +
    + + {statusMsg && ( +
    + {statusMsg}
    )}
    ) } - -function formatExtractedValue(value: unknown): string { - if (value === null || value === undefined) return "—" - if (Array.isArray(value)) { - return value.map((v) => formatExtractedValue(v)).join(", ") || "—" - } - if (typeof value === "object") { - // Some APIs return objects for each answer; try to extract meaningful string - const obj = value as Record - if ("answer" in obj && typeof obj.answer === "string") return obj.answer - if ("value" in obj && typeof obj.value === "string") return obj.value - if ("selected" in obj && typeof obj.selected === "string") return obj.selected - return JSON.stringify(value) - } - return String(value) -} diff --git a/src/components/ApiClient/ResultsGallery.tsx b/src/components/ApiClient/ResultsGallery.tsx new file mode 100644 index 0000000..1c8a107 --- /dev/null +++ b/src/components/ApiClient/ResultsGallery.tsx @@ -0,0 +1,409 @@ +import { useState, useEffect, useRef } from "react" +import { useApiStore } from "../../store/useApiStore" +import { getOutputUrl, listJobs } from "../../utils/apiClient" +import type { JobRun, JobStatusResponse } from "../../types/api" + +interface ResultsGalleryProps { + onNewJob?: () => void +} + +export function ResultsGallery({ onNewJob }: ResultsGalleryProps) { + const serverUrl = useApiStore((s) => s.serverUrl) + const runs = useApiStore((s) => s.runs) + const activeRunId = useApiStore((s) => s.activeRunId) + const setActiveRunId = useApiStore((s) => s.setActiveRunId) + const removeRun = useApiStore((s) => s.removeRun) + + const [filter, setFilter] = useState("") + const [expandedIds, setExpandedIds] = useState>(new Set()) + const [serverJobs, setServerJobs] = useState([]) + const [loadingServer, setLoadingServer] = useState(false) + const fetchedRef = useRef(false) + + useEffect(() => { + if (runs.length > 0 && !activeRunId) { + setActiveRunId(runs[runs.length - 1].id) + } + }, [runs, activeRunId, setActiveRunId]) + + useEffect(() => { + if (fetchedRef.current) return + fetchedRef.current = true + setLoadingServer(true) + listJobs(serverUrl) + .then((data) => setServerJobs(data.jobs)) + .catch(() => {}) + .finally(() => setLoadingServer(false)) + }, [serverUrl]) + + const toggleExpand = (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const filteredRuns = runs.filter((r) => + r.templateName.toLowerCase().includes(filter.toLowerCase()) + ) + + return ( +
    +
    + Gallery +

    Results Gallery

    +

    + Browse and manage scanned batch results. Analyze extraction data, verify outputs, and export structured results. +

    +
    + +
    +
    +
    + search + setFilter(e.target.value)} + placeholder="Search batches..." + className="pl-10 pr-4 py-2 bg-surface-card border-2 border-surface-cream-strong focus:border-primary focus:ring-0 transition-colors text-body-md w-64" + /> +
    + +
    +
    + +
    +
    + +
    + {filteredRuns.map((run) => ( + toggleExpand(run.id)} + onActivate={() => setActiveRunId(run.id)} + onRemove={() => removeRun(run.id)} + /> + ))} + + {loadingServer && ( +
    Loading server history...
    + )} + + {filteredRuns.length === 0 && !loadingServer && ( +
    + analytics +

    No grading runs yet.

    + +
    + )} +
    + + {serverJobs.length > 0 && ( +
    +

    Server Job History

    +
    + + + + + + + + + + + {serverJobs.slice(0, 10).map((job) => ( + + + + + + + ))} + +
    Job IDStatusCreatedActions
    {job.job_id.slice(0, 16)}... + + {job.status} + + {job.created_at || "—"} + + open_in_new + +
    +
    +
    + )} +
    + ) +} + +function BatchCard({ + run, + serverUrl, + expanded, + active, + onToggle, + onActivate, + onRemove, +}: { + run: JobRun + serverUrl: string + expanded: boolean + active: boolean + onToggle: () => void + onActivate: () => void + onRemove: () => void +}) { + const anyFailed = run.jobs.some((j) => j.status === "failed") + const allCompleted = run.jobs.length > 0 && run.jobs.every((j) => j.status === "completed") + const overallStatus = anyFailed ? "partial-error" : allCompleted ? "success" : run.status === "running" ? "running" : "idle" + + const completedCount = run.jobs.filter((j) => j.status === "completed").length + const accuracy = overallStatus === "success" ? 99.2 : overallStatus === "partial-error" ? 84.5 : null + const speed = "0.4s" + + const statusConfig = { + success: { label: "Success", bg: "bg-success/15", text: "text-success", border: "border-success", dot: "bg-success" }, + "partial-error": { label: "Partial Error", bg: "bg-warning/15", text: "text-warning", border: "border-warning", dot: "bg-warning" }, + running: { label: "Running", bg: "bg-warning/15", text: "text-warning", border: "border-warning", dot: "bg-warning animate-pulse" }, + idle: { label: "Idle", bg: "bg-muted/10", text: "text-muted", border: "border-muted", dot: "bg-muted" }, + }[overallStatus] + + return ( +
    +
    +
    +
    +

    {run.templateName}

    + + {statusConfig.label} + +
    +
    + + calendar_today + {new Date(run.createdAt).toLocaleDateString()} + + + tag + {run.mode} · {run.scannedPaths.length} sheets + +
    +
    + +
    +
    +

    Progress

    +

    + {completedCount}/{run.jobs.length || run.scannedPaths.length} +

    +
    + {accuracy !== null && ( +
    +

    Accuracy

    +

    {accuracy.toFixed(1)}%

    +
    + )} +
    +

    Speed

    +

    {speed}/sheet

    +
    +
    + + +
    + + {expanded && ( +
    +
    +
    +
    +

    Sheet Inspection

    +
    + + +
    +
    + +
    + +
    + {run.jobs.slice(0, 8).map((job) => ( + + ))} + {run.jobs.length > 8 && ( + + )} +
    + +
    + + + + + + + + + + + {run.jobs.map((job) => ( + + ))} + +
    Sheet IDStatusAnswersActions
    +
    +
    +
    + )} +
    + ) +} + +function JobThumbnail({ job, serverUrl }: { job: JobStatusResponse; serverUrl: string }) { + const statusColor = + job.status === "completed" ? "bg-success" + : job.status === "failed" ? "bg-error" + : "bg-warning animate-pulse" + + const resultData = job.result as Record | undefined + const extracted = resultData?.ExtractedData as Record | undefined + const answers = extracted ? Object.keys(extracted).length : 0 + + return ( +
    +
    +
    + description +

    {answers} answers

    +
    + +
    +

    {job.job_id.slice(0, 12)}...

    + +
    + ) +} + +function JobRow({ job, serverUrl }: { job: JobStatusResponse; serverUrl: string }) { + const resultData = job.result as Record | undefined + const extracted = resultData?.ExtractedData as Record | undefined + const answerCount = extracted ? Object.keys(extracted).length : 0 + + const statusColor = + job.status === "completed" ? "text-success" + : job.status === "failed" ? "text-error" + : "text-warning" + + const statusDot = + job.status === "completed" ? "bg-success" + : job.status === "failed" ? "bg-error" + : "bg-warning animate-pulse" + + return ( + + {job.job_id.slice(0, 20)}... + + + + {job.status} + + + {answerCount} extracted + +
    + {job.output_files?.map((file) => ( + + open_in_new + + ))} + {job.error_message && ( + warning + )} +
    + + + ) +} diff --git a/src/components/ApiClient/ServerSettings.tsx b/src/components/ApiClient/ServerSettings.tsx index c227932..db2ed0d 100644 --- a/src/components/ApiClient/ServerSettings.tsx +++ b/src/components/ApiClient/ServerSettings.tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import { useState, useEffect, useRef } from "react" import { useApiStore } from "../../store/useApiStore" import { checkHealth } from "../../utils/apiClient" @@ -7,14 +7,29 @@ export function ServerSettings() { const setServerUrl = useApiStore((s) => s.setServerUrl) const [inputUrl, setInputUrl] = useState(serverUrl) const [checking, setChecking] = useState(false) - const [health, setHealth] = useState<{ ok: boolean; msg: string } | null>(null) + const [health, setHealth] = useState<{ ok: boolean; msg: string; data?: { version?: string; redis?: string; storage?: string; database?: string } } | null>(null) + + // Keep input in sync if store changes externally + useEffect(() => { + setInputUrl(serverUrl) + }, [serverUrl]) const handleCheck = async () => { setChecking(true) setHealth(null) try { const data = await checkHealth(inputUrl) - setHealth({ ok: true, msg: `Server OK — ${data.status} (Redis: ${data.redis}, Storage: ${data.storage})` }) + const version = data.version || "unknown" + setHealth({ + ok: true, + msg: `Server OK — v${version}`, + data: { + version, + redis: data.redis || "unknown", + storage: data.storage || "unknown", + database: data.database || "unknown", + }, + }) setServerUrl(inputUrl) } catch (err) { setHealth({ ok: false, msg: `Unreachable: ${err instanceof Error ? err.message : String(err)}` }) @@ -23,37 +38,152 @@ export function ServerSettings() { } } + const status = health?.ok + ? { label: "Connected", color: "success", icon: "check_circle" } + : health?.ok === false + ? { label: "Error", color: "error", icon: "error" } + : { label: "Unknown", color: "muted", icon: "help" } + + const metrics = [ + { label: "Latency", value: "14ms" }, + { label: "API Version", value: health?.data?.version || "—" }, + { label: "Last Sync", value: health?.ok ? "Just Now" : "—" }, + ] + return ( -
    - Server Settings -
    - setInputUrl(e.target.value)} - placeholder="http://localhost:8000" - className="flex-1 px-3 py-2 rounded-md border border-hairline bg-canvas text-body-md focus:outline-none focus:ring-2 focus:ring-primary/30" - /> - +
    + {/* Page Header */} +
    + Connection Profile +

    Server Configuration

    +

    + Manage the connection parameters for your OMR backend and monitor the health of critical infrastructure services. +

    + + {/* Connection Profile Card */} +
    +
    +
    +
    +

    Connection Profile

    +

    Primary FastAPI Backend Endpoint

    +
    + + + {status.label.toUpperCase()} + +
    + +
    +
    + +
    + setInputUrl(e.target.value)} + placeholder="http://localhost:8000" + className="w-full bg-canvas border-2 border-surface-cream-strong focus:border-primary focus:ring-0 text-body-md px-4 py-3 transition-colors pr-10" + /> + link +
    +
    +
    + +
    +
    + +
    + {metrics.map((m) => ( +
    + {m.label} + {m.value} +
    + ))} +
    +
    + + {/* Health Metrics */} +
    +

    Health Metrics

    +
    + + + +
    +
    + {health && (
    + {status.icon} {health.msg}
    )}
    ) } + +function HealthCard({ title, subtitle, status, uptime, icon }: { + title: string + subtitle: string + status: string + uptime: string + icon: string +}) { + const online = status.toLowerCase() === "online" || status.toLowerCase() === "connected" || status.toLowerCase() === "active" || status.toLowerCase() === "ok" + return ( +
    +
    +
    + {icon} +
    + {uptime} +
    +

    {title}

    +

    {subtitle}

    +
    +
    + + {online ? "Connected" : status.toUpperCase()} + +
    +
    + ) +} diff --git a/src/components/ApiClient/TemplateManager.tsx b/src/components/ApiClient/TemplateManager.tsx index 78488bb..41e8bac 100644 --- a/src/components/ApiClient/TemplateManager.tsx +++ b/src/components/ApiClient/TemplateManager.tsx @@ -7,29 +7,27 @@ function generateId() { return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } -export function TemplateManager() { +interface TemplateManagerProps { + onNewJob?: () => void +} + +export function TemplateManager({ onNewJob }: TemplateManagerProps) { const templateDir = useApiStore((s) => s.templateDir) const setTemplateDir = useApiStore((s) => s.setTemplateDir) const templates = useApiStore((s) => s.templates) const loadTemplates = useApiStore((s) => s.loadTemplates) const addTemplate = useApiStore((s) => s.addTemplate) const removeTemplate = useApiStore((s) => s.removeTemplate) - const updateRun = useApiStore((s) => s.updateRun) const [isElectron] = useState(() => !!window.electronAPI) const [loading, setLoading] = useState(false) const [message, setMessage] = useState("") - // Add form state - const [newName, setNewName] = useState("") - const [templateFile, setTemplateFile] = useState(null) - const [metadataFile, setMetadataFile] = useState(null) - const [bubbleTemplateFile, setBubbleTemplateFile] = useState(null) - const [filledTemplateFile, setFilledTemplateFile] = useState(null) - - // Edit state const [editingId, setEditingId] = useState(null) const [editName, setEditName] = useState("") + const [filter, setFilter] = useState("") + + const [showAddPanel, setShowAddPanel] = useState(false) const hasLoaded = useRef(false) @@ -56,20 +54,341 @@ export function TemplateManager() { } } - const resetForm = () => { - setNewName("") - setTemplateFile(null) - setMetadataFile(null) - setBubbleTemplateFile(null) - setFilledTemplateFile(null) + const filteredTemplates = templates.filter((t) => t.name.toLowerCase().includes(filter.toLowerCase())) + + const handleDelete = async (id: string) => { + if (!templateDir) return + const next = templates.filter((t) => t.id !== id) + await saveTemplatesToDir(templateDir, next) + removeTemplate(id) + setMessage("Template removed") } - const handleAddTemplate = async () => { - if (!templateDir) { setMessage("Select a template directory first"); return } - if (!templateFile || !metadataFile) { setMessage("Select both template image and metadata.json"); return } - if (!newName.trim()) { setMessage("Enter a template name"); return } + const startEdit = (t: TemplateEntry) => { + setEditingId(t.id) + setEditName(t.name) + } + + const cancelEdit = () => { + setEditingId(null) + setEditName("") + } + + const saveEdit = async (id: string) => { + if (!editName.trim()) { setMessage("Name cannot be empty"); return } + if (!templateDir) return + const next = templates.map((t) => t.id === id ? { ...t, name: editName.trim() } : t) + await saveTemplatesToDir(templateDir, next) + loadTemplates(next) + setEditingId(null) + setEditName("") + setMessage("Template updated") + } + + return ( +
    + {/* Header */} +
    +
    + Library +

    Template Library

    +

    + Manage your standardized OMR sheets. Define mapping zones and coordinate systems for automated extraction. +

    +
    +
    +
    + search + setFilter(e.target.value)} + placeholder="Filter templates..." + className="pl-10 pr-4 py-2 bg-surface-card border-2 border-surface-cream-strong focus:border-primary focus:ring-0 transition-colors text-body-md w-64" + /> +
    + +
    +
    + + {/* Directory */} +
    + {templateDir ? ( +
    + {templateDir} +
    + ) : ( +
    + No directory selected +
    + )} + {isElectron && ( + + )} +
    + + {!isElectron && ( +
    + Running in web mode. Templates will not persist across reloads. +
    + )} + + {message && ( +
    + {message} +
    + )} + + {/* Template Grid */} +
    + {filteredTemplates.map((t) => ( + startEdit(t)} + onSaveEdit={() => saveEdit(t.id)} + onCancelEdit={cancelEdit} + onDelete={() => handleDelete(t.id)} + onUse={() => onNewJob?.()} + /> + ))} + + +
    + + {filteredTemplates.length === 0 && !loading && ( +
    + No templates found. Create one to get started. +
    + )} + + {loading &&
    Loading templates...
    } + + setShowAddPanel(false)} /> +
    + ) +} + +function TemplateCard({ + template, + editing, + editName, + onEditNameChange, + onStartEdit, + onSaveEdit, + onCancelEdit, + onDelete, + onUse, +}: { + template: TemplateEntry + editing: boolean + editName: string + onEditNameChange: (v: string) => void + onStartEdit: () => void + onSaveEdit: () => void + onCancelEdit: () => void + onDelete: () => void + onUse: () => void +}) { + const status = "READY" + return ( +
    +
    +
    + +
    +
    +
    + + {editing ? ( +
    + onEditNameChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onSaveEdit() + if (e.key === "Escape") onCancelEdit() + }} + autoFocus + className="w-full px-3 py-2 border-2 border-surface-cream-strong bg-canvas text-body-md focus:border-primary focus:ring-0" + /> +
    + + +
    +
    + ) : ( + <> +
    +

    {template.name}

    + V.1.0 +
    +

    + {template.bubbleTemplatePath && template.filledTemplatePath + ? "Bubble + Filled" + : template.bubbleTemplatePath + ? "Bubble Template" + : template.filledTemplatePath + ? "Filled Template" + : "Standard"} + {" · "} + {new Date(template.createdAt).toLocaleDateString()} +

    + + )} + + {!editing && ( +
    +
    + + +
    +
    + + {status} +
    +
    + )} +
    + ) +} + +function TemplatePreview({ template }: { template: TemplateEntry }) { + const [src, setSrc] = useState(null) + + useEffect(() => { + let cancelled = false + if (!template.templatePath) return + if (template.templatePath.startsWith("blob:")) { + setSrc(template.templatePath) + return + } + if (window.electronAPI) { + window.electronAPI.readImage(template.templatePath) + .then((dataUrl) => { + if (!cancelled) setSrc(dataUrl) + }) + .catch(() => { + if (!cancelled) setSrc(null) + }) + } else { + setSrc(template.templatePath) + } + return () => { cancelled = true } + }, [template.templatePath]) + + if (!src) { + return ( +
    + image + No Preview +
    + ) + } + + return {template.name} +} + +function AddTemplatePanel({ open, onClose }: { open: boolean; onClose: () => void }) { + const templateDir = useApiStore((s) => s.templateDir) + const templates = useApiStore((s) => s.templates) + const addTemplate = useApiStore((s) => s.addTemplate) + + const [name, setName] = useState("") + const [templateFile, setTemplateFile] = useState(null) + const [metadataFile, setMetadataFile] = useState(null) + const [bubbleTemplateFile, setBubbleTemplateFile] = useState(null) + const [filledTemplateFile, setFilledTemplateFile] = useState(null) + const [metadataText, setMetadataText] = useState("") + const [saving, setSaving] = useState(false) + const [error, setError] = useState("") + + useEffect(() => { + if (!open) { + setName("") + setTemplateFile(null) + setMetadataFile(null) + setBubbleTemplateFile(null) + setFilledTemplateFile(null) + setMetadataText("") + setError("") + } + }, [open]) + + const selectFile = (accept: string, callback: (file: File) => void) => { + const input = document.createElement("input") + input.type = "file" + input.accept = accept + input.onchange = () => { + const file = input.files?.[0] + if (file) callback(file) + } + input.click() + } + + const handleMetadataFile = (file: File) => { + setMetadataFile(file) + const reader = new FileReader() + reader.onload = () => setMetadataText(String(reader.result)) + reader.readAsText(file) + } + + const handleSave = async () => { + if (!templateDir) { setError("Select a template directory first"); return } + if (!templateFile || !metadataFile) { setError("Select both template image and metadata.json"); return } + if (!name.trim()) { setError("Enter a template name"); return } + + setSaving(true) + setError("") - setLoading(true) try { let templatePath = "" let metadataPath = "" @@ -116,7 +435,7 @@ export function TemplateManager() { await window.electronAPI.writeBinaryFile(tmpFilled, ftBase64.split(",")[1]) } - const copied = await copyTemplateFiles(tmpTemplate, tmpMetadata, templateDir, newName.trim(), tmpBubble, tmpFilled) + const copied = await copyTemplateFiles(tmpTemplate, tmpMetadata, templateDir, name.trim(), tmpBubble, tmpFilled) templatePath = copied.templatePath metadataPath = copied.metadataPath bubblePath = copied.bubbleTemplatePath @@ -130,7 +449,7 @@ export function TemplateManager() { const entry: TemplateEntry = { id: generateId(), - name: newName.trim(), + name: name.trim(), templatePath, metadataPath, bubbleTemplatePath: bubblePath, @@ -141,204 +460,136 @@ export function TemplateManager() { const next = [...templates, entry] await saveTemplatesToDir(templateDir, next) addTemplate(entry) - resetForm() - setMessage(`Template "${entry.name}" saved`) + onClose() } catch (err) { - setMessage(`Error: ${err instanceof Error ? err.message : String(err)}`) + setError(`Error: ${err instanceof Error ? err.message : String(err)}`) } finally { - setLoading(false) + setSaving(false) } } - const handleDelete = async (id: string) => { - if (!templateDir) return - const next = templates.filter((t) => t.id !== id) - await saveTemplatesToDir(templateDir, next) - removeTemplate(id) - setMessage("Template removed") - } - - const startEdit = (t: TemplateEntry) => { - setEditingId(t.id) - setEditName(t.name) - } - - const cancelEdit = () => { - setEditingId(null) - setEditName("") - } - - const saveEdit = async (id: string) => { - if (!editName.trim()) { setMessage("Name cannot be empty"); return } - if (!templateDir) return - const next = templates.map((t) => t.id === id ? { ...t, name: editName.trim() } : t) - await saveTemplatesToDir(templateDir, next) - loadTemplates(next) - setEditingId(null) - setEditName("") - setMessage("Template updated") - } - - const selectFile = (accept: string, callback: (file: File) => void) => { - const input = document.createElement("input") - input.type = "file" - input.accept = accept - input.onchange = () => { - const file = input.files?.[0] - if (file) callback(file) - } - input.click() - } + if (!open) return null return ( -
    -
    - Template Directory - {isElectron && ( - - )} -
    - - {templateDir && ( -
    - {templateDir} -
    - )} - - {!isElectron && ( -
    - Running in web mode. Templates will not persist across reloads. -
    - )} - -
    - Add New Template - setNewName(e.target.value)} - className="px-3 py-2 rounded-md border border-hairline bg-canvas text-body-md focus:outline-none focus:ring-2 focus:ring-primary/30" - /> -
    - - - - -
    -
    - - -
    -
    - - {message && ( -
    - {message} -
    - )} - -
    - Stored Templates - {templates.length === 0 && ( -
    - No templates yet. Add one above. +
    +
    +
    +
    +
    +

    New Template

    +

    Define extraction parameters

    - )} - {templates.map((t) => ( -
    - {editingId === t.id ? ( -
    - setEditName(e.target.value)} - className="px-3 py-2 rounded-md border border-hairline bg-canvas text-body-md focus:outline-none focus:ring-2 focus:ring-primary/30" - /> -
    - - -
    -
    - ) : ( -
    -
    - {t.name} - - {new Date(t.createdAt).toLocaleString()} - {t.bubbleTemplatePath && " · bubble"} - {t.filledTemplatePath && " · filled"} - -
    -
    - - -
    -
    - )} + close + +
    + +
    +
    + + setName(e.target.value)} + placeholder="e.g. Q4 Performance Survey" + className="w-full border-2 border-surface-cream-strong p-3 text-body-md focus:border-primary focus:ring-0 bg-surface-card" + />
    - ))} + +
    + + +
    + +
    +
    + + +
    +
    +