Compare commits
3 Commits
66129d4315
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ebcc4bb572 | |||
| 52b7fdb1c2 | |||
| 02241edfca |
+4
-1
@@ -33,4 +33,7 @@ dist-ssr
|
||||
|
||||
# Files
|
||||
ARCHITECTURE.md
|
||||
DESIGN.md
|
||||
DESIGN.md
|
||||
|
||||
# Folders
|
||||
api-client-design
|
||||
+1
-1
@@ -185,7 +185,7 @@ export default function App() {
|
||||
</TabErrorBoundary>
|
||||
) : (
|
||||
<TabErrorBoundary tab="API Client" key="api">
|
||||
<div className="flex-1 overflow-auto bg-canvas">
|
||||
<div className="flex-1 overflow-hidden bg-canvas">
|
||||
<ApiClientPanel />
|
||||
</div>
|
||||
</TabErrorBoundary>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
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 ApiSection = "server" | "templates" | "jobs" | "results"
|
||||
|
||||
interface EBProps {
|
||||
children: ReactNode
|
||||
name: string
|
||||
}
|
||||
|
||||
interface EBState {
|
||||
hasError: boolean
|
||||
error: string
|
||||
}
|
||||
|
||||
class SafeWrap extends Component<EBProps, EBState> {
|
||||
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 (
|
||||
<div className="m-4 p-4 rounded-lg border border-error/20 bg-error/5 text-error">
|
||||
<div className="text-caption font-medium mb-1">{this.props.name} crashed</div>
|
||||
<pre className="text-code whitespace-pre-wrap">{this.state.error}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
const sections: { key: ApiSection; 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" },
|
||||
]
|
||||
|
||||
function LeftPanel({ active, onChange }: { active: ApiSection; onChange: (s: ApiSection) => void }) {
|
||||
const runs = useApiStore((s) => s.runs)
|
||||
return (
|
||||
<aside className="w-[260px] bg-surface-card border-r border-hairline flex flex-col shrink-0">
|
||||
<div className="px-4 py-3 border-b border-hairline flex items-center justify-between gap-2">
|
||||
<h2 className="text-caption-uppercase text-muted tracking-widest flex items-center gap-2 min-w-0">
|
||||
<span className="material-symbols-outlined text-[16px] text-primary shrink-0">api</span>
|
||||
<span className="truncate">API Client</span>
|
||||
</h2>
|
||||
<span className="text-caption text-muted-soft">{sections.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-3 border-b border-hairline-soft">
|
||||
<button
|
||||
onClick={() => onChange("jobs")}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-primary text-on-primary rounded-lg text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all shadow-sm"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||
New Job
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-1">
|
||||
{sections.map((sec) => {
|
||||
const isActive = active === sec.key
|
||||
const count = sec.key === "results" ? runs.length : undefined
|
||||
return (
|
||||
<div key={sec.key} className="group">
|
||||
<button
|
||||
onClick={() => onChange(sec.key)}
|
||||
className={`w-full flex items-center justify-between px-4 py-2.5 text-left transition-all ${
|
||||
isActive
|
||||
? "border-l-[3px] border-primary bg-primary/[0.06]"
|
||||
: "border-l-[3px] border-transparent hover:bg-surface-cream-strong/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`material-symbols-outlined text-[16px] shrink-0 ${isActive ? "text-primary" : "text-muted-soft"}`}>
|
||||
{sec.icon}
|
||||
</span>
|
||||
<span className={`text-title-sm truncate ${isActive ? "text-ink font-medium" : "text-body"}`}>
|
||||
{sec.label}
|
||||
</span>
|
||||
</div>
|
||||
{count !== undefined && (
|
||||
<span className="text-caption text-muted-soft">{count}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="px-4 py-2.5 border-t border-hairline bg-surface-cream-strong/20 flex justify-between items-center">
|
||||
<span className="text-caption text-muted-soft">
|
||||
<span className="font-medium text-body">{runs.length}</span> runs
|
||||
</span>
|
||||
<span className="text-caption text-muted-soft">
|
||||
<span className="font-medium text-body">{useApiStore.getState().templates.length}</span> templates
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApiClientLayout() {
|
||||
const [active, setActive] = useState<ApiSection>("server")
|
||||
const setActiveRunId = useApiStore((s) => s.setActiveRunId)
|
||||
|
||||
const handleChange = (section: ApiSection) => {
|
||||
setActive(section)
|
||||
if (section === "results") {
|
||||
const runs = useApiStore.getState().runs
|
||||
if (runs.length > 0 && !useApiStore.getState().activeRunId) {
|
||||
setActiveRunId(runs[runs.length - 1].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = {
|
||||
server: <ServerSettings />,
|
||||
templates: <TemplateManager onNewJob={() => handleChange("jobs")} />,
|
||||
jobs: <JobRunner onViewResults={() => handleChange("results")} />,
|
||||
results: <ResultsGallery onNewJob={() => handleChange("jobs")} />,
|
||||
}[active]
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-canvas text-ink">
|
||||
<LeftPanel active={active} onChange={handleChange} />
|
||||
<main className="flex-1 overflow-auto custom-scrollbar">
|
||||
<div className="max-w-[1000px] mx-auto px-8 py-8 pb-12">
|
||||
<SafeWrap name={active}>{content}</SafeWrap>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<EBProps, EBState> {
|
||||
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 (
|
||||
<div className="m-4 p-4 rounded-lg border border-error/20 bg-error/5 text-error">
|
||||
<div className="text-caption font-medium mb-1">{this.props.name} crashed</div>
|
||||
<pre className="text-code whitespace-pre-wrap">{this.state.error}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
import { ApiClientLayout } from "./ApiClientLayout"
|
||||
|
||||
export function ApiClientPanel() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<SafeWrap name="ServerSettings">
|
||||
<ServerSettings />
|
||||
</SafeWrap>
|
||||
<SafeWrap name="TemplateManager">
|
||||
<TemplateManager />
|
||||
</SafeWrap>
|
||||
<SafeWrap name="JobRunner">
|
||||
<JobRunner />
|
||||
</SafeWrap>
|
||||
</div>
|
||||
)
|
||||
return <ApiClientLayout />
|
||||
}
|
||||
|
||||
@@ -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,14 +29,15 @@ async function imageFileFromPath(path: string): Promise<File> {
|
||||
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<string>("")
|
||||
const [scannedPaths, setScannedPaths] = useState<string[]>([])
|
||||
@@ -166,7 +167,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 +180,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 +194,122 @@ export function JobRunner() {
|
||||
}
|
||||
}
|
||||
|
||||
const activeRun = runs.find((r) => r.id === activeRunId) || runs[runs.length - 1] || null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex flex-col gap-3 bg-surface-card border border-hairline rounded-lg p-4">
|
||||
<span className="text-title-sm font-medium text-ink">Run Grading</span>
|
||||
<div className="space-y-6">
|
||||
<div className="mb-2">
|
||||
<h2 className="text-display-sm font-serif text-ink mb-1">New Grading Job</h2>
|
||||
<p className="text-body-md text-muted max-w-2xl">Configure the template, upload scanned sheets, and start the OMR grading process.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-caption text-muted">Select Template</label>
|
||||
<Panel title="01. Select Template" icon="description">
|
||||
<div className="relative max-w-md">
|
||||
<select
|
||||
value={selectedTemplateId}
|
||||
onChange={(e) => setSelectedTemplateId(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"
|
||||
className="w-full h-10 px-3 bg-canvas border border-hairline rounded-md focus:border-primary focus:ring-1 focus:ring-primary/20 outline-none text-body-md appearance-none"
|
||||
>
|
||||
<option value="">-- Choose a template --</option>
|
||||
{templates.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="material-symbols-outlined absolute right-3 top-2.5 pointer-events-none text-muted">expand_more</span>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-caption text-muted">Scanned Sheets</label>
|
||||
<button
|
||||
onClick={pickScannedFiles}
|
||||
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
|
||||
>
|
||||
{scannedPaths.length > 0
|
||||
? `${scannedPaths.length} file(s) selected`
|
||||
: "Select Scanned Images"}
|
||||
</button>
|
||||
{scannedPaths.length > 0 && (
|
||||
<ul className="text-caption text-muted mt-1 max-h-24 overflow-auto bg-surface-soft rounded-md px-2 py-1">
|
||||
<Panel title="02. Upload Scanned Sheets" icon="upload_file">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<button
|
||||
onClick={pickScannedFiles}
|
||||
className="w-full border border-dashed border-hairline rounded-lg h-48 flex flex-col items-center justify-center bg-canvas hover:bg-surface-soft transition-colors group"
|
||||
>
|
||||
<span className="material-symbols-outlined text-3xl text-muted mb-3 group-hover:scale-110 transition-transform">cloud_upload</span>
|
||||
<p className="text-button text-ink">Select scanned images</p>
|
||||
<p className="text-xs text-muted mt-1">PNG, JPG · Max 500MB per sheet</p>
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<span className="text-caption text-muted font-medium">Selected Files ({scannedPaths.length})</span>
|
||||
<div className="space-y-2 max-h-44 overflow-y-auto pr-1 custom-scrollbar">
|
||||
{scannedPaths.length === 0 && (
|
||||
<div className="p-3 bg-canvas border border-hairline rounded-md text-caption text-muted text-center">No files selected</div>
|
||||
)}
|
||||
{scannedPaths.map((p, i) => (
|
||||
<li key={i}>{p.split(/[/\\]/).pop() || p}</li>
|
||||
<div key={i} className="flex items-center justify-between p-2 bg-canvas border border-hairline rounded-md text-[11px] font-mono">
|
||||
<span className="truncate pr-2">{p.split(/[/\\]/).pop() || p}</span>
|
||||
<button
|
||||
onClick={() => setScannedPaths((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
className="text-muted hover:text-error"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-caption text-muted">Processing Mode</label>
|
||||
<div className="flex gap-2">
|
||||
{(["async", "sync"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={
|
||||
mode === m
|
||||
? "flex-1 px-3 py-2 rounded-md bg-primary/10 text-primary border border-primary/20 text-nav font-medium transition-all"
|
||||
: "flex-1 px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all"
|
||||
}
|
||||
>
|
||||
{m === "async" ? "Async (poll)" : "Sync (block)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="03. Configuration" icon="settings_input_component">
|
||||
<div className="flex flex-wrap gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-button font-medium">Processing Mode</span>
|
||||
<div className="flex bg-canvas p-1 border border-hairline rounded-md w-fit">
|
||||
<button
|
||||
onClick={() => setMode("sync")}
|
||||
className={`px-4 py-1.5 rounded text-button font-medium transition-all ${
|
||||
mode === "sync"
|
||||
? "bg-surface-card text-primary border border-primary shadow-sm"
|
||||
: "text-muted hover:bg-surface-soft"
|
||||
}`}
|
||||
>
|
||||
Synchronous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode("async")}
|
||||
className={`px-4 py-1.5 rounded text-button font-medium transition-all ${
|
||||
mode === "async"
|
||||
? "bg-surface-card text-primary border border-primary shadow-sm"
|
||||
: "text-muted hover:bg-surface-soft"
|
||||
}`}
|
||||
>
|
||||
Asynchronous
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className="pt-4 flex flex-col items-center gap-4">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !selectedTemplateId || scannedPaths.length === 0}
|
||||
className="px-4 py-2 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all shadow-sm disabled:opacity-50"
|
||||
className="px-8 py-3 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all disabled:bg-primary-disabled disabled:text-muted shadow-sm flex items-center gap-2"
|
||||
>
|
||||
{submitting ? "Submitting..." : "Grade Now"}
|
||||
<span className="material-symbols-outlined">play_arrow</span>
|
||||
{submitting ? "Submitting..." : "Start Grading Process"}
|
||||
</button>
|
||||
|
||||
{statusMsg && (
|
||||
<div className="text-caption px-3 py-2 rounded-md border bg-surface-soft border-hairline text-body">
|
||||
{statusMsg}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs font-mono text-muted uppercase tracking-wider">
|
||||
Estimated time: <span className="text-primary font-bold">~{Math.max(1, scannedPaths.length * 14)} seconds</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-title-sm font-medium text-ink">Runs</span>
|
||||
{runs.length === 0 && (
|
||||
<div className="text-caption text-muted px-3 py-4 rounded-md border border-hairline bg-surface-soft text-center">
|
||||
No grading runs yet.
|
||||
</div>
|
||||
)}
|
||||
{runs.map((run) => (
|
||||
<button
|
||||
key={run.id}
|
||||
onClick={() => setActiveRunId(run.id)}
|
||||
className={
|
||||
(activeRunId === run.id
|
||||
? "border-primary/30 bg-primary/5 "
|
||||
: "border-hairline bg-surface-soft hover:bg-surface-card ") +
|
||||
"flex flex-col gap-1 px-3 py-2 rounded-md border text-left transition-all"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-nav font-medium text-ink">{run.templateName}</span>
|
||||
<span className={
|
||||
run.status === "completed"
|
||||
? "text-caption text-success font-medium"
|
||||
: run.status === "failed"
|
||||
? "text-caption text-error font-medium"
|
||||
: "text-caption text-warning font-medium"
|
||||
}>
|
||||
{run.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-caption text-muted">
|
||||
{run.scannedPaths.length} sheet(s) · {run.mode} · {new Date(run.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeRun && <ResultsPanel run={activeRun} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultsPanel({ run }: { run: JobRun }) {
|
||||
const serverUrl = useApiStore((s) => s.serverUrl)
|
||||
const [expandedJob, setExpandedJob] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-3 bg-surface-card border border-hairline rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-title-sm font-medium text-ink">Results</span>
|
||||
<span className={
|
||||
overallStatus === "completed"
|
||||
? "text-caption text-success font-medium px-2 py-0.5 rounded bg-success/10"
|
||||
: overallStatus === "failed"
|
||||
? "text-caption text-error font-medium px-2 py-0.5 rounded bg-error/10"
|
||||
: "text-caption text-warning font-medium px-2 py-0.5 rounded bg-warning/10"
|
||||
}>
|
||||
{overallStatus.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{run.jobs.length === 0 && (
|
||||
<div className="text-caption text-muted px-3 py-4 rounded-md border border-hairline bg-surface-soft text-center">
|
||||
No jobs yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.jobs.map((job) => (
|
||||
<JobResultCard
|
||||
key={job.job_id}
|
||||
job={job}
|
||||
serverUrl={serverUrl}
|
||||
expanded={expandedJob === job.job_id}
|
||||
onToggle={() => setExpandedJob(expandedJob === job.job_id ? null : job.job_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string, unknown> | undefined
|
||||
const extracted = resultData?.ExtractedData as Record<string, unknown> | undefined
|
||||
|
||||
return (
|
||||
<div className={`rounded-md border p-3 transition-all ${statusColor}`}>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{job.status === "completed" ? "check_circle" : job.status === "failed" ? "error" : "pending"}
|
||||
</span>
|
||||
<span className="text-nav font-medium">Job {job.job_id.slice(0, 8)}...</span>
|
||||
</div>
|
||||
<span className="text-caption font-medium uppercase">{job.status}</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-3 mt-3 animate-fade-in">
|
||||
{job.error_message && (
|
||||
<div className="text-caption text-error bg-error/10 px-3 py-2 rounded border border-error/20">
|
||||
{job.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extracted && Object.keys(extracted).length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-caption text-muted font-medium">Extracted Answers</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{Object.entries(extracted).map(([qNum, rawValue]) => {
|
||||
const label = isNaN(Number(qNum)) ? qNum : `Q${qNum}`
|
||||
const answers = formatExtractedValue(rawValue)
|
||||
return (
|
||||
<div
|
||||
key={qNum}
|
||||
className="flex items-center justify-between px-3 py-2 rounded-md bg-canvas border border-hairline"
|
||||
>
|
||||
<span className="text-caption text-muted">{label}</span>
|
||||
<span className="text-nav font-medium text-ink">{answers}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.output_files && job.output_files.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-caption text-muted font-medium">Output Files</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{job.output_files.map((file) => (
|
||||
<a
|
||||
key={file}
|
||||
href={getOutputUrl(serverUrl, job.job_id, file)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md bg-canvas border border-hairline text-caption text-primary hover:bg-surface-soft transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">open_in_new</span>
|
||||
<span className="truncate">{file}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.result && !extracted && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-caption text-muted font-medium">Raw Result</span>
|
||||
<pre className="text-code bg-canvas border border-hairline rounded-md p-2 overflow-auto max-h-48">
|
||||
{JSON.stringify(job.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{statusMsg && (
|
||||
<div className="text-caption text-body bg-canvas border border-hairline px-3 py-2 rounded-md font-medium">{statusMsg}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
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)
|
||||
function Panel({ title, icon, children }: { title: string; icon: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-surface-card border border-hairline rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">{icon}</span>
|
||||
<h3 className="text-title-md font-medium text-ink">{title}</h3>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
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<Set<string>>(new Set())
|
||||
const [serverJobs, setServerJobs] = useState<JobStatusResponse[]>([])
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-display-sm font-serif text-ink mb-1">Results Gallery</h2>
|
||||
<p className="text-body-md text-muted max-w-2xl">Browse grading runs, inspect extracted data, and export results.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onNewJob}
|
||||
className="px-4 py-2 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||
New Job
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 p-4 bg-surface-card border border-hairline rounded-lg">
|
||||
<div className="relative">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-muted text-sm">search</span>
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Search batches..."
|
||||
className="pl-9 pr-3 py-2 bg-canvas border border-hairline rounded-md text-body-sm focus:border-primary focus:ring-1 focus:ring-primary/20 outline-none transition-all w-64"
|
||||
/>
|
||||
</div>
|
||||
<select className="px-3 py-2 bg-canvas border border-hairline rounded-md text-body-sm focus:border-primary focus:ring-1 focus:ring-primary/20 outline-none">
|
||||
<option>All Templates</option>
|
||||
{Array.from(new Set(runs.map((r) => r.templateName))).map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{filteredRuns.map((run) => (
|
||||
<BatchCard
|
||||
key={run.id}
|
||||
run={run}
|
||||
serverUrl={serverUrl}
|
||||
expanded={expandedIds.has(run.id)}
|
||||
active={run.id === activeRunId}
|
||||
onToggle={() => toggleExpand(run.id)}
|
||||
onActivate={() => setActiveRunId(run.id)}
|
||||
onRemove={() => removeRun(run.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loadingServer && <div className="text-caption text-muted text-center py-8">Loading server history...</div>}
|
||||
|
||||
{filteredRuns.length === 0 && !loadingServer && (
|
||||
<div className="text-center py-12 rounded-lg border border-dashed border-hairline bg-canvas">
|
||||
<span className="material-symbols-outlined text-4xl text-muted mb-4 block">analytics</span>
|
||||
<p className="text-body-md text-muted mb-4">No grading runs yet.</p>
|
||||
<button
|
||||
onClick={onNewJob}
|
||||
className="px-5 py-2 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all"
|
||||
>
|
||||
Start a Job
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{serverJobs.length > 0 && (
|
||||
<div className="mt-8 pt-6 border-t border-hairline">
|
||||
<h3 className="text-title-md font-medium mb-4">Server Job History</h3>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr className="bg-canvas border-b border-hairline">
|
||||
<th className="p-3 text-[10px] font-mono uppercase text-muted">Job ID</th>
|
||||
<th className="p-3 text-[10px] font-mono uppercase text-muted">Status</th>
|
||||
<th className="p-3 text-[10px] font-mono uppercase text-muted">Created</th>
|
||||
<th className="p-3 text-[10px] font-mono uppercase text-muted text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono text-code">
|
||||
{serverJobs.slice(0, 10).map((job) => (
|
||||
<tr key={job.job_id} className="border-b border-hairline hover:bg-surface-soft transition-colors">
|
||||
<td className="p-3">{job.job_id.slice(0, 16)}...</td>
|
||||
<td className="p-3">
|
||||
<StatusBadge status={job.status} />
|
||||
</td>
|
||||
<td className="p-3 text-muted-soft">{job.created_at || "—"}</td>
|
||||
<td className="p-3 text-right">
|
||||
<a
|
||||
href={getOutputUrl(serverUrl, job.job_id, "result.json")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="material-symbols-outlined text-lg text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
open_in_new
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 statusConfig = {
|
||||
success: { label: "Success", bg: "bg-success/10", text: "text-success", border: "border-success", dot: "bg-success" },
|
||||
"partial-error": { label: "Partial Error", bg: "bg-warning/10", text: "text-warning", border: "border-warning", dot: "bg-warning" },
|
||||
running: { label: "Running", bg: "bg-warning/10", 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 (
|
||||
<div className={`bg-surface-card border rounded-lg overflow-hidden transition-all ${active ? "border-primary" : "border-hairline"}`}>
|
||||
<div
|
||||
className={`flex items-center justify-between p-4 border-l-[3px] ${statusConfig.border} cursor-pointer hover:bg-surface-soft transition-colors`}
|
||||
onClick={onActivate}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-title-md font-medium text-ink truncate">{run.templateName}</h3>
|
||||
<span className={`text-[10px] font-mono px-2 py-0.5 rounded uppercase ${statusConfig.bg} ${statusConfig.text} border border-current`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-muted text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-base">calendar_today</span>
|
||||
{new Date(run.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-base">tag</span>
|
||||
{run.mode} · {run.scannedPaths.length} sheets
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 mr-4 shrink-0">
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] font-mono uppercase text-muted mb-0.5">Progress</p>
|
||||
<p className="text-title-sm font-medium text-ink">{completedCount}/{run.jobs.length || run.scannedPaths.length}</p>
|
||||
</div>
|
||||
{accuracy !== null && (
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] font-mono uppercase text-muted mb-0.5">Accuracy</p>
|
||||
<p className={`text-title-sm font-medium ${statusConfig.text}`}>{accuracy.toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle()
|
||||
}}
|
||||
className="p-1.5 text-muted hover:text-ink hover:bg-surface-cream-strong rounded-md transition-colors shrink-0"
|
||||
>
|
||||
<span className={`material-symbols-outlined transition-transform ${expanded ? "rotate-180" : ""}`}>keyboard_arrow_down</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-hairline bg-canvas animate-fade-in">
|
||||
<div className="p-4 space-y-5">
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="flex gap-3">
|
||||
<button className="px-4 py-2 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all flex items-center gap-2 shadow-sm">
|
||||
<span className="material-symbols-outlined text-base">download</span>
|
||||
Export CSV
|
||||
</button>
|
||||
<button className="px-4 py-2 border border-primary text-primary rounded-md text-button font-medium hover:bg-primary/5 active:scale-[0.98] transition-all flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-base">picture_as_pdf</span>
|
||||
Export PDF Report
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1.5 text-muted hover:text-error hover:bg-error/5 rounded-md transition-colors"
|
||||
title="Remove run"
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{run.jobs.slice(0, 12).map((job) => (
|
||||
<JobThumbnail key={job.job_id} job={job} serverUrl={serverUrl} />
|
||||
))}
|
||||
{run.jobs.length > 12 && (
|
||||
<button className="bg-surface-soft border border-dashed border-hairline rounded-lg flex flex-col items-center justify-center p-3 hover:bg-surface-card transition-all">
|
||||
<span className="material-symbols-outlined text-2xl text-muted">more_horiz</span>
|
||||
<span className="text-caption text-muted">+{run.jobs.length - 12} more</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<thead>
|
||||
<tr className="bg-canvas border-b border-hairline">
|
||||
<th className="p-3 text-[10px] font-mono uppercase text-muted">Sheet ID</th>
|
||||
<th className="p-3 text-[10px] font-mono uppercase text-muted">Status</th>
|
||||
<th className="p-3 text-[10px] font-mono uppercase text-muted">Answers</th>
|
||||
<th className="p-3 text-[10px] font-mono uppercase text-muted text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono text-code">
|
||||
{run.jobs.map((job) => (
|
||||
<JobRow key={job.job_id} job={job} serverUrl={serverUrl} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string, unknown> | undefined
|
||||
const extracted = resultData?.ExtractedData as Record<string, unknown> | undefined
|
||||
const answers = extracted ? Object.keys(extracted).length : 0
|
||||
|
||||
return (
|
||||
<div className="group relative bg-surface-card border border-hairline rounded-lg p-2 cursor-pointer hover:border-primary transition-colors">
|
||||
<div className="aspect-[3/4] bg-canvas mb-2 rounded overflow-hidden relative flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<span className="material-symbols-outlined text-2xl text-muted">description</span>
|
||||
<p className="text-[10px] font-mono text-muted mt-1">{answers} answers</p>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<a
|
||||
href={job.output_files?.[0] ? getOutputUrl(serverUrl, job.job_id, job.output_files[0]) : "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="bg-surface-card p-1.5 material-symbols-outlined text-primary rounded"
|
||||
>
|
||||
visibility
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] font-mono text-center truncate">{job.job_id.slice(0, 10)}...</p>
|
||||
<span className={`absolute top-2 right-2 w-2 h-2 rounded-full ${statusColor}`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function JobRow({ job, serverUrl }: { job: JobStatusResponse; serverUrl: string }) {
|
||||
const resultData = job.result as Record<string, unknown> | undefined
|
||||
const extracted = resultData?.ExtractedData as Record<string, unknown> | undefined
|
||||
const answerCount = extracted ? Object.keys(extracted).length : 0
|
||||
|
||||
return (
|
||||
<tr className="border-b border-hairline hover:bg-surface-soft transition-colors">
|
||||
<td className="p-3">{job.job_id.slice(0, 18)}...</td>
|
||||
<td className="p-3">
|
||||
<StatusBadge status={job.status} />
|
||||
</td>
|
||||
<td className="p-3 text-muted-soft">{answerCount} extracted</td>
|
||||
<td className="p-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{job.output_files?.map((file) => (
|
||||
<a
|
||||
key={file}
|
||||
href={getOutputUrl(serverUrl, job.job_id, file)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="material-symbols-outlined text-lg text-muted hover:text-primary transition-colors"
|
||||
title={file}
|
||||
>
|
||||
open_in_new
|
||||
</a>
|
||||
))}
|
||||
{job.error_message && (
|
||||
<span className="material-symbols-outlined text-lg text-error" title={job.error_message}>warning</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config =
|
||||
status === "completed" ? { color: "text-success", dot: "bg-success" }
|
||||
: status === "failed" ? { color: "text-error", dot: "bg-error" }
|
||||
: { color: "text-warning", dot: "bg-warning animate-pulse" }
|
||||
|
||||
return (
|
||||
<span className={`flex items-center gap-1.5 ${config.color}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${config.dot}`} />
|
||||
<span className="text-caption font-medium uppercase">{status}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Table({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-x-auto custom-scrollbar bg-surface-card border border-hairline rounded-lg">
|
||||
<table className="w-full text-left border-collapse">{children}</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useApiStore } from "../../store/useApiStore"
|
||||
import { checkHealth } from "../../utils/apiClient"
|
||||
|
||||
@@ -7,14 +7,28 @@ 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)
|
||||
|
||||
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 +37,123 @@ 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" }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4 bg-surface-card border border-hairline rounded-lg">
|
||||
<span className="text-title-sm font-medium text-ink">Server Settings</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCheck}
|
||||
disabled={checking}
|
||||
className="px-4 py-2 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all shadow-sm disabled:opacity-50"
|
||||
>
|
||||
{checking ? "Checking..." : "Check"}
|
||||
</button>
|
||||
</div>
|
||||
{health && (
|
||||
<div
|
||||
className={
|
||||
(health.ok
|
||||
? "text-success bg-success/5 border-success/20 "
|
||||
: "text-error bg-error/5 border-error/20 ") +
|
||||
"text-caption px-3 py-2 rounded-md border"
|
||||
}
|
||||
>
|
||||
{health.msg}
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Server Configuration" subtitle="Manage the connection parameters for your OMR backend and monitor infrastructure health." />
|
||||
|
||||
<Panel title="Connection Profile" icon="link">
|
||||
<div className="flex flex-col md:flex-row gap-3 items-end">
|
||||
<div className="flex-1 w-full">
|
||||
<label className="text-caption text-muted block mb-1.5 font-medium">FastAPI Backend URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
placeholder="http://localhost:8000"
|
||||
className="w-full bg-canvas border border-hairline rounded-md px-3 py-2 text-body-md text-ink focus:border-primary focus:ring-1 focus:ring-primary/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCheck}
|
||||
disabled={checking}
|
||||
className="px-4 py-2 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all disabled:bg-primary-disabled disabled:text-muted h-10 shrink-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] align-middle mr-1">{checking ? "sync" : "bolt"}</span>
|
||||
{checking ? "Checking..." : "Test Connection"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{health && (
|
||||
<div
|
||||
className={
|
||||
(health.ok
|
||||
? "text-success bg-success/10 border-success/20 "
|
||||
: "text-error bg-error/10 border-error/20 ") +
|
||||
"mt-4 text-caption font-medium px-3 py-2 rounded-md border"
|
||||
}
|
||||
role={health.ok ? "status" : "alert"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] align-middle mr-2">{status.icon}</span>
|
||||
{health.msg}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<HealthCard
|
||||
title="Redis Cluster"
|
||||
subtitle="Cache & Messaging"
|
||||
status={health?.data?.redis || "unknown"}
|
||||
icon="database"
|
||||
/>
|
||||
<HealthCard
|
||||
title="File Storage"
|
||||
subtitle="Object Store"
|
||||
status={health?.data?.storage || "unknown"}
|
||||
icon="folder_open"
|
||||
/>
|
||||
<HealthCard
|
||||
title="Main Database"
|
||||
subtitle="Relational Records"
|
||||
status={health?.data?.database || "unknown"}
|
||||
icon="storage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HealthCard({ title, subtitle, status, icon }: {
|
||||
title: string
|
||||
subtitle: string
|
||||
status: string
|
||||
icon: string
|
||||
}) {
|
||||
const online = ["online", "connected", "active", "ok", "read/write ok", "active pool"].includes(status.toLowerCase())
|
||||
return (
|
||||
<div className="bg-surface-cream-strong/20 border border-hairline rounded-lg p-4 hover:bg-surface-cream-strong/30 transition-colors">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">{icon}</span>
|
||||
<div>
|
||||
<h4 className="text-title-sm font-medium text-ink">{title}</h4>
|
||||
<p className="text-caption text-muted-soft">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${online ? "bg-success" : "bg-warning"}`} />
|
||||
<span className={`text-caption font-medium uppercase ${online ? "text-success" : "text-warning"}`}>
|
||||
{status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PageHeader({ title, subtitle }: { title: string; subtitle: string }) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-display-sm font-serif text-ink mb-1">{title}</h2>
|
||||
<p className="text-body-md text-muted max-w-2xl">{subtitle}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Panel({ title, icon, children }: { title: string; icon: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-surface-card border border-hairline rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">{icon}</span>
|
||||
<h3 className="text-title-md font-medium text-ink">{title}</h3>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,29 +7,26 @@ 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<File | null>(null)
|
||||
const [metadataFile, setMetadataFile] = useState<File | null>(null)
|
||||
const [bubbleTemplateFile, setBubbleTemplateFile] = useState<File | null>(null)
|
||||
const [filledTemplateFile, setFilledTemplateFile] = useState<File | null>(null)
|
||||
|
||||
// Edit state
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [filter, setFilter] = useState("")
|
||||
const [showAddPanel, setShowAddPanel] = useState(false)
|
||||
|
||||
const hasLoaded = useRef(false)
|
||||
|
||||
@@ -56,20 +53,321 @@ 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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-display-sm font-serif text-ink mb-1">Template Library</h2>
|
||||
<p className="text-body-md text-muted max-w-md">Manage standardized OMR sheets and their coordinate mappings.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-muted text-sm">search</span>
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter templates..."
|
||||
className="pl-9 pr-3 py-2 bg-canvas border border-hairline rounded-md text-body-sm focus:border-primary focus:ring-1 focus:ring-primary/20 outline-none transition-all w-56"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddPanel(true)}
|
||||
className="px-4 py-2 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||
Create Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Panel title="Storage Directory" icon="folder">
|
||||
<div className="flex items-center gap-2">
|
||||
{templateDir ? (
|
||||
<div className="text-body-sm text-muted bg-canvas px-3 py-2 rounded-md border border-hairline flex-1 truncate font-mono">
|
||||
{templateDir}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-body-sm text-muted-soft bg-canvas px-3 py-2 rounded-md border border-hairline flex-1 truncate">
|
||||
No directory selected
|
||||
</div>
|
||||
)}
|
||||
{isElectron && (
|
||||
<button
|
||||
onClick={pickDirectory}
|
||||
className="px-3 py-2 rounded-md bg-surface-soft border border-hairline text-nav text-ink hover:bg-surface-cream-strong active:scale-[0.98] transition-all h-10 shrink-0"
|
||||
>
|
||||
{templateDir ? "Change" : "Choose"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isElectron && (
|
||||
<div className="mt-3 text-caption text-warning bg-warning/10 px-3 py-2 rounded-md border border-warning/20 font-medium">
|
||||
Running in web mode. Templates will not persist across reloads.
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{message && (
|
||||
<div className="text-caption text-body bg-canvas border border-hairline px-3 py-2 rounded-md font-medium">{message}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredTemplates.map((t) => (
|
||||
<TemplateCard
|
||||
key={t.id}
|
||||
template={t}
|
||||
editing={editingId === t.id}
|
||||
editName={editName}
|
||||
onEditNameChange={setEditName}
|
||||
onStartEdit={() => startEdit(t)}
|
||||
onSaveEdit={() => saveEdit(t.id)}
|
||||
onCancelEdit={cancelEdit}
|
||||
onDelete={() => handleDelete(t.id)}
|
||||
onUse={() => onNewJob?.()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredTemplates.length === 0 && !loading && (
|
||||
<div className="text-caption text-muted px-4 py-8 rounded-md border border-dashed border-hairline bg-canvas text-center">
|
||||
No templates found. Create one to get started.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="text-caption text-muted text-center py-8">Loading templates...</div>}
|
||||
|
||||
<AddTemplatePanel open={showAddPanel} onClose={() => setShowAddPanel(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-surface-card border border-hairline rounded-lg p-4 flex flex-col hover:bg-surface-cream-strong/20 transition-colors">
|
||||
<div className="relative aspect-[3/4] bg-canvas mb-4 rounded-md border border-hairline overflow-hidden flex items-center justify-center">
|
||||
<TemplatePreview template={template} />
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => 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 border-hairline bg-canvas rounded-md text-body-md text-ink focus:border-primary focus:ring-1 focus:ring-primary/20 outline-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onSaveEdit}
|
||||
className="flex-1 px-3 py-1.5 rounded-md bg-primary text-on-primary text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelEdit}
|
||||
className="flex-1 px-3 py-1.5 rounded-md border border-hairline bg-surface-soft text-button text-ink hover:bg-surface-cream-strong active:scale-[0.98] transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<h3 className="text-title-md font-medium text-ink truncate">{template.name}</h3>
|
||||
<span className="text-[10px] font-mono bg-canvas px-2 py-0.5 rounded border border-hairline text-muted shrink-0">V.1.0</span>
|
||||
</div>
|
||||
<p className="text-caption text-muted mb-3">
|
||||
{template.bubbleTemplatePath && template.filledTemplatePath
|
||||
? "Bubble + Filled"
|
||||
: template.bubbleTemplatePath
|
||||
? "Bubble Template"
|
||||
: template.filledTemplatePath
|
||||
? "Filled Template"
|
||||
: "Standard"}
|
||||
{" · "}
|
||||
{new Date(template.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!editing && (
|
||||
<div className="mt-auto pt-3 border-t border-hairline flex justify-between items-center">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={onStartEdit}
|
||||
className="p-1.5 rounded-md text-muted hover:text-primary hover:bg-primary/5 transition-all"
|
||||
title="Edit name"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1.5 rounded-md text-muted hover:text-error hover:bg-error/5 transition-all"
|
||||
title="Delete"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onUse}
|
||||
className="px-3 py-1.5 rounded-md bg-primary/10 text-primary text-caption font-medium hover:bg-primary/20 active:scale-[0.97] transition-all"
|
||||
>
|
||||
Use in Job
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplatePreview({ template }: { template: TemplateEntry }) {
|
||||
const [src, setSrc] = useState<string | null>(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 (
|
||||
<div className="flex flex-col items-center text-muted-soft">
|
||||
<span className="material-symbols-outlined text-3xl mb-1">image</span>
|
||||
<span className="text-[10px] font-mono uppercase">No Preview</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <img src={src} alt={template.name} className="w-full h-full object-contain" />
|
||||
}
|
||||
|
||||
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<File | null>(null)
|
||||
const [metadataFile, setMetadataFile] = useState<File | null>(null)
|
||||
const [bubbleTemplateFile, setBubbleTemplateFile] = useState<File | null>(null)
|
||||
const [filledTemplateFile, setFilledTemplateFile] = useState<File | null>(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 +414,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 +428,7 @@ export function TemplateManager() {
|
||||
|
||||
const entry: TemplateEntry = {
|
||||
id: generateId(),
|
||||
name: newName.trim(),
|
||||
name: name.trim(),
|
||||
templatePath,
|
||||
metadataPath,
|
||||
bubbleTemplatePath: bubblePath,
|
||||
@@ -141,205 +439,146 @@ 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 (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-title-sm font-medium text-ink">Template Directory</span>
|
||||
{isElectron && (
|
||||
<button
|
||||
onClick={pickDirectory}
|
||||
className="px-3 py-1.5 rounded-md bg-surface-card border border-hairline text-nav hover:bg-surface-cream-strong transition-all"
|
||||
>
|
||||
{templateDir ? "Change Directory" : "Choose Directory"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{templateDir && (
|
||||
<div className="text-caption text-muted bg-surface-soft px-3 py-2 rounded-md border border-hairline">
|
||||
{templateDir}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isElectron && (
|
||||
<div className="text-caption text-warning bg-warning/5 px-3 py-2 rounded-md border border-warning/20">
|
||||
Running in web mode. Templates will not persist across reloads.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 bg-surface-card border border-hairline rounded-lg p-4">
|
||||
<span className="text-title-sm font-medium text-ink">Add New Template</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Template name"
|
||||
value={newName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => selectFile("image/png,image/jpeg", setTemplateFile)}
|
||||
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
|
||||
>
|
||||
{templateFile ? `Template: ${templateFile.name}` : "Select Template Image"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectFile("application/json", setMetadataFile)}
|
||||
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
|
||||
>
|
||||
{metadataFile ? `Metadata: ${metadataFile.name}` : "Select metadata.json"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectFile("image/png,image/jpeg", setBubbleTemplateFile)}
|
||||
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
|
||||
>
|
||||
{bubbleTemplateFile ? `Bubble: ${bubbleTemplateFile.name}` : "Select Bubble Template (opt)"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectFile("image/png,image/jpeg", setFilledTemplateFile)}
|
||||
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
|
||||
>
|
||||
{filledTemplateFile ? `Filled: ${filledTemplateFile.name}` : "Select Filled Bubble (opt)"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAddTemplate}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all shadow-sm disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Saving..." : "Save Template"}
|
||||
</button>
|
||||
<button
|
||||
onClick={resetForm}
|
||||
className="px-4 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="text-caption px-3 py-2 rounded-md border bg-surface-soft border-hairline text-body">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-title-sm font-medium text-ink">Stored Templates</span>
|
||||
{templates.length === 0 && (
|
||||
<div className="text-caption text-muted px-3 py-4 rounded-md border border-hairline bg-surface-soft text-center">
|
||||
No templates yet. Add one above.
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="absolute inset-0 bg-surface-dark/40 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="absolute right-0 top-0 bottom-0 w-full max-w-md bg-surface-card shadow-2xl flex flex-col">
|
||||
<div className="p-5 border-b border-hairline flex justify-between items-center bg-canvas">
|
||||
<div>
|
||||
<h2 className="text-title-lg font-medium text-ink">New Template</h2>
|
||||
<p className="text-caption text-muted">Define extraction parameters</p>
|
||||
</div>
|
||||
)}
|
||||
{templates.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex flex-col gap-2 px-3 py-2 rounded-md border border-hairline bg-surface-soft hover:bg-surface-card transition-all"
|
||||
>
|
||||
{editingId === t.id ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => saveEdit(t.id)}
|
||||
className="px-3 py-1.5 rounded-md bg-primary text-on-primary text-nav font-medium hover:bg-primary-active transition-all"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="px-3 py-1.5 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-nav font-medium text-ink">{t.name}</span>
|
||||
<span className="text-caption text-muted">
|
||||
{new Date(t.createdAt).toLocaleString()}
|
||||
{t.bubbleTemplatePath && " · bubble"}
|
||||
{t.filledTemplatePath && " · filled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => startEdit(t)}
|
||||
className="p-1.5 rounded-md text-muted hover:text-primary hover:bg-primary/5 transition-all"
|
||||
title="Edit name"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(t.id)}
|
||||
className="p-1.5 rounded-md text-muted hover:text-error hover:bg-error/5 transition-all"
|
||||
title="Delete"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={onClose} className="p-2 text-muted hover:text-ink hover:bg-surface-soft rounded-md transition-colors">
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-5 custom-scrollbar">
|
||||
<Field label="Template Name">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Q4 Performance Survey"
|
||||
className="w-full bg-canvas border border-hairline rounded-md px-3 py-2 text-body-md text-ink focus:border-primary focus:ring-1 focus:ring-primary/20 outline-none transition-all"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-caption text-muted font-medium">Reference Blank Image</label>
|
||||
<button
|
||||
onClick={() => selectFile("image/png,image/jpeg", setTemplateFile)}
|
||||
className={`w-full border border-dashed border-hairline rounded-lg p-8 text-center hover:bg-surface-soft transition-colors ${templateFile ? "bg-primary/5 border-primary/30" : "bg-canvas"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-3xl text-muted mb-2 block">upload_file</span>
|
||||
<p className="text-body-sm text-ink">{templateFile ? templateFile.name : "Drop blank scan or browse"}</p>
|
||||
{!templateFile && <p className="text-[10px] font-mono text-muted mt-1">MAX 10MB • PNG, JPG</p>}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-caption text-muted font-medium">JSON Configuration</label>
|
||||
<button
|
||||
onClick={() => selectFile("application/json", handleMetadataFile)}
|
||||
className="text-[10px] font-mono text-primary hover:underline uppercase"
|
||||
>
|
||||
Upload .JSON
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={metadataText}
|
||||
onChange={(e) => setMetadataText(e.target.value)}
|
||||
placeholder='{
|
||||
"template_id": "auto_gen",
|
||||
"fields": [
|
||||
{ "id": "q1", "type": "bubble", "coords": [120, 450] }
|
||||
]
|
||||
}'
|
||||
rows={8}
|
||||
className="w-full border border-hairline rounded-md p-3 font-mono text-code bg-surface-dark text-on-dark focus:border-primary focus:ring-1 focus:ring-primary/20 outline-none custom-scrollbar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FileChip
|
||||
label="Bubble Template"
|
||||
filename={bubbleTemplateFile?.name}
|
||||
onClick={() => selectFile("image/png,image/jpeg", setBubbleTemplateFile)}
|
||||
/>
|
||||
<FileChip
|
||||
label="Filled Bubble"
|
||||
filename={filledTemplateFile?.name}
|
||||
onClick={() => selectFile("image/png,image/jpeg", setFilledTemplateFile)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-caption text-error bg-error/10 px-3 py-2 rounded-md border border-error/20">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5 border-t border-hairline bg-canvas flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 bg-primary text-on-primary rounded-md text-button font-medium py-2.5 hover:bg-primary-active active:scale-[0.97] transition-all disabled:bg-primary-disabled disabled:text-muted"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Template"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 border border-hairline bg-surface-soft text-button text-ink rounded-md py-2.5 hover:bg-surface-cream-strong active:scale-[0.98] transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileChip({ label, filename, onClick }: { label: string; filename?: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`border rounded-lg p-3 text-left transition-all ${
|
||||
filename ? "border-primary bg-primary/5" : "border-hairline bg-surface-card hover:bg-surface-soft"
|
||||
}`}
|
||||
>
|
||||
<p className="text-caption font-medium mb-0.5">{label}</p>
|
||||
<p className="text-[11px] text-muted truncate">{filename || "Optional"}</p>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Panel({ title, icon, children }: { title: string; icon: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-surface-card border border-hairline rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">{icon}</span>
|
||||
<h3 className="text-title-md font-medium text-ink">{title}</h3>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-caption text-muted font-medium">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ export function TabBar({ active, onChange }: TabBarProps) {
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => onChange(t.key)}
|
||||
className={
|
||||
className={`px-4 py-1.5 rounded-md text-nav font-medium transition-all ${
|
||||
active === t.key
|
||||
? "px-4 py-1.5 rounded-md bg-primary/10 text-primary border border-primary/20 text-nav font-medium transition-all"
|
||||
: "px-4 py-1.5 rounded-md text-muted hover:text-ink hover:bg-surface-card text-nav font-medium transition-all"
|
||||
}
|
||||
? "bg-surface-cream-strong text-ink border border-hairline"
|
||||
: "text-muted hover:text-ink hover:bg-surface-card"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
|
||||
--animate-fade-in: fade-in 0.2s ease-out;
|
||||
--animate-slide-up: slide-up 0.2s ease-out;
|
||||
--animate-shimmer: shimmer 1.5s infinite ease-in-out;
|
||||
|
||||
--color-canvas-dot: rgba(108,106,100,0.08);
|
||||
}
|
||||
@@ -133,6 +134,11 @@
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
|
||||
display: inline-block;
|
||||
|
||||
Reference in New Issue
Block a user