style: api client design overhaul
This commit is contained in:
@@ -5,14 +5,7 @@ import { TemplateManager } from "./TemplateManager"
|
|||||||
import { JobRunner } from "./JobRunner"
|
import { JobRunner } from "./JobRunner"
|
||||||
import { ResultsGallery } from "./ResultsGallery"
|
import { ResultsGallery } from "./ResultsGallery"
|
||||||
|
|
||||||
type ApiTab = "server" | "templates" | "jobs" | "results"
|
type ApiSection = "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 {
|
interface EBProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -51,74 +44,85 @@ class SafeWrap extends Component<EBProps, EBState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sidebar({ active, onChange }: { active: ApiTab; onChange: (t: ApiTab) => void }) {
|
const sections: { key: ApiSection; label: string; icon: string }[] = [
|
||||||
const setActiveTab = (tab: ApiTab) => {
|
{ key: "server", label: "Server", icon: "dns" },
|
||||||
if (tab === "jobs" || tab === "results") {
|
{ key: "templates", label: "Templates", icon: "description" },
|
||||||
// Auto-activate works in both directions
|
{ key: "jobs", label: "Jobs", icon: "play_circle" },
|
||||||
}
|
{ key: "results", label: "Results", icon: "analytics" },
|
||||||
onChange(tab)
|
]
|
||||||
}
|
|
||||||
|
|
||||||
|
function LeftPanel({ active, onChange }: { active: ApiSection; onChange: (s: ApiSection) => void }) {
|
||||||
|
const runs = useApiStore((s) => s.runs)
|
||||||
return (
|
return (
|
||||||
<aside className="w-64 h-full flex flex-col bg-surface-soft border-r-2 border-hairline pt-8 pb-4 shrink-0">
|
<aside className="w-[260px] bg-surface-card border-r border-hairline flex flex-col shrink-0">
|
||||||
<div className="px-6 mb-8">
|
<div className="px-4 py-3 border-b border-hairline flex items-center justify-between gap-2">
|
||||||
<h1 className="text-display-sm font-serif text-primary leading-tight">Admin Console</h1>
|
<h2 className="text-caption-uppercase text-muted tracking-widest flex items-center gap-2 min-w-0">
|
||||||
<p className="text-caption text-muted mt-1">Local Instance</p>
|
<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>
|
||||||
|
|
||||||
<nav className="flex-1 space-y-1">
|
<div className="px-3 py-3 border-b border-hairline-soft">
|
||||||
{tabs.map((t) => {
|
<button
|
||||||
const isActive = active === t.key
|
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 (
|
return (
|
||||||
<button
|
<div key={sec.key} className="group">
|
||||||
key={t.key}
|
<button
|
||||||
onClick={() => setActiveTab(t.key)}
|
onClick={() => onChange(sec.key)}
|
||||||
className={`w-full flex items-center gap-3 px-6 py-3 text-left transition-all ${
|
className={`w-full flex items-center justify-between px-4 py-2.5 text-left transition-all ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-surface-cream-strong text-primary font-bold border-r-4 border-primary"
|
? "border-l-[3px] border-primary bg-primary/[0.06]"
|
||||||
: "text-muted hover:text-ink hover:bg-surface-card hover:translate-y-[-1px] active:scale-[0.98]"
|
: "border-l-[3px] border-transparent hover:bg-surface-cream-strong/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined" style={{ fontVariationSettings: isActive ? "'FILL' 1" : undefined }}>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{t.icon}
|
<span className={`material-symbols-outlined text-[16px] shrink-0 ${isActive ? "text-primary" : "text-muted-soft"}`}>
|
||||||
</span>
|
{sec.icon}
|
||||||
<span className="text-nav">{t.label}</span>
|
</span>
|
||||||
</button>
|
<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>
|
</nav>
|
||||||
|
|
||||||
<div className="px-6 mt-4">
|
<div className="px-4 py-2.5 border-t border-hairline bg-surface-cream-strong/20 flex justify-between items-center">
|
||||||
<button
|
<span className="text-caption text-muted-soft">
|
||||||
onClick={() => setActiveTab("jobs")}
|
<span className="font-medium text-body">{runs.length}</span> runs
|
||||||
className="w-full bg-primary text-on-primary py-3 px-4 flex items-center justify-center gap-2 border-2 border-primary hover:bg-primary-active hover:shadow-sm transition-all active:scale-[0.98]"
|
</span>
|
||||||
>
|
<span className="text-caption text-muted-soft">
|
||||||
<span className="material-symbols-outlined">add</span>
|
<span className="font-medium text-body">{useApiStore.getState().templates.length}</span> templates
|
||||||
<span className="text-button">New Job</span>
|
</span>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto border-t border-hairline pt-4 space-y-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("server")}
|
|
||||||
className="w-full flex items-center gap-3 px-6 py-2 text-left text-muted hover:text-ink hover:bg-surface-card transition-all"
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-[20px]">settings</span>
|
|
||||||
<span className="text-body-sm">Settings</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ApiClientLayout() {
|
export function ApiClientLayout() {
|
||||||
const [activeTab, setActiveTab] = useState<ApiTab>("server")
|
const [active, setActive] = useState<ApiSection>("server")
|
||||||
const setActiveRunId = useApiStore((s) => s.setActiveRunId)
|
const setActiveRunId = useApiStore((s) => s.setActiveRunId)
|
||||||
|
|
||||||
const handleChange = (tab: ApiTab) => {
|
const handleChange = (section: ApiSection) => {
|
||||||
setActiveTab(tab)
|
setActive(section)
|
||||||
if (tab === "results") {
|
if (section === "results") {
|
||||||
// Keep last run selected if any
|
|
||||||
const runs = useApiStore.getState().runs
|
const runs = useApiStore.getState().runs
|
||||||
if (runs.length > 0 && !useApiStore.getState().activeRunId) {
|
if (runs.length > 0 && !useApiStore.getState().activeRunId) {
|
||||||
setActiveRunId(runs[runs.length - 1].id)
|
setActiveRunId(runs[runs.length - 1].id)
|
||||||
@@ -128,17 +132,17 @@ export function ApiClientLayout() {
|
|||||||
|
|
||||||
const content = {
|
const content = {
|
||||||
server: <ServerSettings />,
|
server: <ServerSettings />,
|
||||||
templates: <TemplateManager onNewJob={() => setActiveTab("jobs")} />,
|
templates: <TemplateManager onNewJob={() => handleChange("jobs")} />,
|
||||||
jobs: <JobRunner onViewResults={() => setActiveTab("results")} />,
|
jobs: <JobRunner onViewResults={() => handleChange("results")} />,
|
||||||
results: <ResultsGallery onNewJob={() => setActiveTab("jobs")} />,
|
results: <ResultsGallery onNewJob={() => handleChange("jobs")} />,
|
||||||
}[activeTab]
|
}[active]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full bg-canvas text-ink">
|
<div className="flex h-full bg-canvas text-ink">
|
||||||
<Sidebar active={activeTab} onChange={handleChange} />
|
<LeftPanel active={active} onChange={handleChange} />
|
||||||
<main className="flex-1 overflow-auto custom-scrollbar">
|
<main className="flex-1 overflow-auto custom-scrollbar">
|
||||||
<div className="max-w-[1000px] mx-auto px-8 py-8 pb-12">
|
<div className="max-w-[1000px] mx-auto px-8 py-8 pb-12">
|
||||||
<SafeWrap name={activeTab}>{content}</SafeWrap>
|
<SafeWrap name={active}>{content}</SafeWrap>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export function JobRunner({ onViewResults }: JobRunnerProps) {
|
|||||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("")
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("")
|
||||||
const [scannedPaths, setScannedPaths] = useState<string[]>([])
|
const [scannedPaths, setScannedPaths] = useState<string[]>([])
|
||||||
const [mode, setMode] = useState<"sync" | "async">("async")
|
const [mode, setMode] = useState<"sync" | "async">("async")
|
||||||
const [outputFormat, setOutputFormat] = useState<"json" | "csv" | "both">("json")
|
|
||||||
const [statusMsg, setStatusMsg] = useState("")
|
const [statusMsg, setStatusMsg] = useState("")
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const pollRefs = useRef<Record<string, boolean>>({})
|
const pollRefs = useRef<Record<string, boolean>>({})
|
||||||
@@ -196,164 +195,121 @@ export function JobRunner({ onViewResults }: JobRunnerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-6">
|
||||||
<header className="mb-8">
|
<div className="mb-2">
|
||||||
<span className="text-caption-uppercase text-primary uppercase tracking-widest mb-2 block">Workflow Runner</span>
|
<h2 className="text-display-sm font-serif text-ink mb-1">New Grading Job</h2>
|
||||||
<h2 className="text-display-sm font-serif text-ink mb-2">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>
|
||||||
<p className="text-body-lg text-body max-w-2xl">
|
</div>
|
||||||
Initialize a high-precision optical mark recognition task. Configure your template, ingest scanned documents, and define the processing mode.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
<Panel title="01. Select Template" icon="description">
|
||||||
{/* Step 1: Select Template */}
|
<div className="relative max-w-md">
|
||||||
<section className="bg-surface-card border-2 border-surface-cream-strong p-6 shadow-sm border-l-4 border-l-primary">
|
<select
|
||||||
<div className="flex items-start justify-between mb-6">
|
value={selectedTemplateId}
|
||||||
<div>
|
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
||||||
<h3 className="text-title-lg font-medium text-ink mb-1">01. Select Template</h3>
|
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"
|
||||||
<p className="text-body-sm text-muted">Choose the structural layout for the OMR processing engine.</p>
|
>
|
||||||
</div>
|
<option value="">-- Choose a template --</option>
|
||||||
<span className="material-symbols-outlined text-primary">description</span>
|
{templates.map((t) => (
|
||||||
</div>
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
<div className="relative max-w-md">
|
))}
|
||||||
<select
|
</select>
|
||||||
value={selectedTemplateId}
|
<span className="material-symbols-outlined absolute right-3 top-2.5 pointer-events-none text-muted">expand_more</span>
|
||||||
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
</div>
|
||||||
className="w-full h-12 px-4 bg-canvas border-2 border-surface-cream-strong focus:border-primary focus:ring-0 appearance-none text-body-md transition-colors"
|
</Panel>
|
||||||
|
|
||||||
|
<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"
|
||||||
>
|
>
|
||||||
<option value="">-- Choose a template --</option>
|
<span className="material-symbols-outlined text-3xl text-muted mb-3 group-hover:scale-110 transition-transform">cloud_upload</span>
|
||||||
{templates.map((t) => (
|
<p className="text-button text-ink">Select scanned images</p>
|
||||||
<option key={t.id} value={t.id}>{t.name}</option>
|
<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) => (
|
||||||
|
<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>
|
||||||
))}
|
))}
|
||||||
</select>
|
|
||||||
<span className="material-symbols-outlined absolute right-4 top-3 pointer-events-none text-muted">expand_more</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Step 2: Upload Scanned Sheets */}
|
|
||||||
<section className="bg-surface-card border-2 border-surface-cream-strong p-6 shadow-sm border-l-4 border-l-primary">
|
|
||||||
<div className="flex items-start justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-title-lg font-medium text-ink mb-1">02. Upload Scanned Sheets</h3>
|
|
||||||
<p className="text-body-sm text-muted">Ingest high-resolution JPEG, PNG files for batch analysis.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="material-symbols-outlined text-primary">upload_file</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<Panel title="03. Configuration" icon="settings_input_component">
|
||||||
<div className="md:col-span-2">
|
<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
|
<button
|
||||||
onClick={pickScannedFiles}
|
onClick={() => setMode("sync")}
|
||||||
className="w-full border-2 border-dashed border-surface-cream-strong h-64 flex flex-col items-center justify-center bg-canvas hover:bg-surface-soft transition-colors cursor-pointer group"
|
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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-4xl mb-4 text-muted group-hover:scale-110 transition-transform">cloud_upload</span>
|
Synchronous
|
||||||
<p className="text-button text-ink">Select scanned images</p>
|
</button>
|
||||||
<p className="text-xs text-muted mt-2">Max file size: 500MB per sheet</p>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="text-[10px] font-mono uppercase text-muted">Selected Files ({scannedPaths.length})</h4>
|
|
||||||
<div className="space-y-2 max-h-52 overflow-y-auto pr-2 custom-scrollbar">
|
|
||||||
{scannedPaths.length === 0 && (
|
|
||||||
<div className="p-3 bg-canvas border-2 border-surface-cream-strong text-caption text-muted text-center">No files selected</div>
|
|
||||||
)}
|
|
||||||
{scannedPaths.map((p, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex items-center justify-between p-3 bg-canvas border-2 border-surface-cream-strong 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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Step 3: Configuration */}
|
|
||||||
<section className="bg-surface-card border-2 border-surface-cream-strong p-6 shadow-sm border-l-4 border-l-primary">
|
|
||||||
<div className="flex items-start justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-title-lg font-medium text-ink mb-1">03. Configuration</h3>
|
|
||||||
<p className="text-body-sm text-muted">Define processing priority and output format.</p>
|
|
||||||
</div>
|
|
||||||
<span className="material-symbols-outlined text-primary">settings_input_component</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-12">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span className="text-button font-medium">Processing Mode</span>
|
|
||||||
<div className="flex bg-canvas p-1 border-2 border-surface-cream-strong w-fit">
|
|
||||||
<button
|
|
||||||
onClick={() => setMode("sync")}
|
|
||||||
className={`px-6 py-2 text-button font-medium transition-all ${
|
|
||||||
mode === "sync"
|
|
||||||
? "bg-surface-card border-2 border-primary text-primary shadow-sm"
|
|
||||||
: "text-muted hover:bg-surface-soft"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Synchronous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setMode("async")}
|
|
||||||
className={`px-6 py-2 text-button font-medium transition-all ${
|
|
||||||
mode === "async"
|
|
||||||
? "bg-surface-card border-2 border-primary text-primary shadow-sm"
|
|
||||||
: "text-muted hover:bg-surface-soft"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Asynchronous
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span className="text-button font-medium">Output Format</span>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{(["json", "csv", "both"] as const).map((fmt) => (
|
|
||||||
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="output-format"
|
|
||||||
checked={outputFormat === fmt}
|
|
||||||
onChange={() => setOutputFormat(fmt)}
|
|
||||||
className="w-5 h-5 accent-primary border-2 border-surface-cream-strong"
|
|
||||||
/>
|
|
||||||
<span className="font-mono text-code uppercase">{fmt === "both" ? "JSON + CSV" : fmt}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Final Action */}
|
|
||||||
<div className="pt-8 flex flex-col items-center justify-center gap-6">
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={submitting || !selectedTemplateId || scannedPaths.length === 0}
|
|
||||||
className="bg-primary text-on-primary text-title-md font-medium py-5 px-16 border-2 border-primary hover:bg-primary-active active:scale-95 transition-all shadow-lg disabled:bg-primary-disabled disabled:text-muted disabled:border-primary-disabled disabled:shadow-none group flex items-center gap-4"
|
|
||||||
>
|
|
||||||
{submitting ? "Submitting..." : "Start Grading Process"}
|
|
||||||
{!submitting && <span className="material-symbols-outlined group-hover:translate-x-2 transition-transform">arrow_forward</span>}
|
|
||||||
</button>
|
|
||||||
<p className="text-xs font-mono text-muted uppercase tracking-widest">
|
|
||||||
Estimated processing time: <span className="text-primary font-bold">~{Math.max(1, scannedPaths.length * 14)} seconds</span>
|
|
||||||
</p>
|
|
||||||
</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-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"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined">play_arrow</span>
|
||||||
|
{submitting ? "Submitting..." : "Start Grading Process"}
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
|
||||||
{statusMsg && (
|
{statusMsg && (
|
||||||
<div className="mt-8 text-caption text-body bg-canvas border-2 border-hairline px-4 py-3 rounded-md font-medium">
|
<div className="text-caption text-body bg-canvas border border-hairline px-3 py-2 rounded-md font-medium">{statusMsg}</div>
|
||||||
{statusMsg}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,45 +50,41 @@ export function ResultsGallery({ onNewJob }: ResultsGalleryProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
<header className="mb-8">
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
<span className="text-caption-uppercase text-primary uppercase tracking-widest mb-2 block">Gallery</span>
|
<div>
|
||||||
<h2 className="text-display-sm font-serif text-ink mb-2">Results Gallery</h2>
|
<h2 className="text-display-sm font-serif text-ink mb-1">Results Gallery</h2>
|
||||||
<p className="text-body-lg text-body max-w-2xl">
|
<p className="text-body-md text-muted max-w-2xl">Browse grading runs, inspect extracted data, and export results.</p>
|
||||||
Browse and manage scanned batch results. Analyze extraction data, verify outputs, and export structured results.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4 p-6 bg-surface-soft border-2 border-surface-cream-strong">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<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-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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select className="px-4 py-2 bg-surface-card border-2 border-surface-cream-strong focus:border-primary focus:ring-0 transition-colors text-body-md">
|
|
||||||
<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 items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={onNewJob}
|
|
||||||
className="bg-primary text-on-primary px-5 py-2 text-button border-2 border-primary hover:bg-primary-active active:scale-[0.98] transition-all"
|
|
||||||
>
|
|
||||||
New Job
|
|
||||||
</button>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6">
|
<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) => (
|
{filteredRuns.map((run) => (
|
||||||
<BatchCard
|
<BatchCard
|
||||||
key={run.id}
|
key={run.id}
|
||||||
@@ -102,17 +98,15 @@ export function ResultsGallery({ onNewJob }: ResultsGalleryProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{loadingServer && (
|
{loadingServer && <div className="text-caption text-muted text-center py-8">Loading server history...</div>}
|
||||||
<div className="text-caption text-muted text-center py-8">Loading server history...</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredRuns.length === 0 && !loadingServer && (
|
{filteredRuns.length === 0 && !loadingServer && (
|
||||||
<div className="text-center py-12 bg-canvas border-2 border-dashed border-hairline rounded-md">
|
<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>
|
<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>
|
<p className="text-body-md text-muted mb-4">No grading runs yet.</p>
|
||||||
<button
|
<button
|
||||||
onClick={onNewJob}
|
onClick={onNewJob}
|
||||||
className="bg-primary text-on-primary px-6 py-2 text-button border-2 border-primary hover:bg-primary-active active:scale-[0.98] transition-all"
|
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
|
Start a Job
|
||||||
</button>
|
</button>
|
||||||
@@ -121,49 +115,39 @@ export function ResultsGallery({ onNewJob }: ResultsGalleryProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{serverJobs.length > 0 && (
|
{serverJobs.length > 0 && (
|
||||||
<div className="mt-12 pt-8 border-t-2 border-surface-cream-strong">
|
<div className="mt-8 pt-6 border-t border-hairline">
|
||||||
<h3 className="text-title-md font-medium mb-4">Server Job History</h3>
|
<h3 className="text-title-md font-medium mb-4">Server Job History</h3>
|
||||||
<div className="overflow-x-auto custom-scrollbar bg-surface-card border-2 border-surface-cream-strong">
|
<Table>
|
||||||
<table className="w-full text-left border-collapse">
|
<thead>
|
||||||
<thead>
|
<tr className="bg-canvas border-b border-hairline">
|
||||||
<tr className="bg-canvas border-b-2 border-surface-cream-strong">
|
<th className="p-3 text-[10px] font-mono uppercase text-muted">Job ID</th>
|
||||||
<th className="p-4 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-4 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-4 text-[10px] font-mono uppercase text-muted">Created</th>
|
<th className="p-3 text-[10px] font-mono uppercase text-muted text-right">Actions</th>
|
||||||
<th className="p-4 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>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody className="font-mono text-code">
|
</tbody>
|
||||||
{serverJobs.slice(0, 10).map((job) => (
|
</Table>
|
||||||
<tr key={job.job_id} className="border-b border-hairline hover:bg-surface-soft transition-colors">
|
|
||||||
<td className="p-4">{job.job_id.slice(0, 16)}...</td>
|
|
||||||
<td className="p-4">
|
|
||||||
<span className={`text-caption font-medium uppercase ${
|
|
||||||
job.status === "completed"
|
|
||||||
? "text-success"
|
|
||||||
: job.status === "failed"
|
|
||||||
? "text-error"
|
|
||||||
: "text-warning"
|
|
||||||
}`}>
|
|
||||||
{job.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="p-4 text-muted-soft">{job.created_at || "—"}</td>
|
|
||||||
<td className="p-4 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -193,33 +177,28 @@ function BatchCard({
|
|||||||
|
|
||||||
const completedCount = run.jobs.filter((j) => j.status === "completed").length
|
const completedCount = run.jobs.filter((j) => j.status === "completed").length
|
||||||
const accuracy = overallStatus === "success" ? 99.2 : overallStatus === "partial-error" ? 84.5 : null
|
const accuracy = overallStatus === "success" ? 99.2 : overallStatus === "partial-error" ? 84.5 : null
|
||||||
const speed = "0.4s"
|
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
success: { label: "Success", bg: "bg-success/15", text: "text-success", border: "border-success", dot: "bg-success" },
|
success: { label: "Success", bg: "bg-success/10", 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" },
|
"partial-error": { label: "Partial Error", bg: "bg-warning/10", 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" },
|
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" },
|
idle: { label: "Idle", bg: "bg-muted/10", text: "text-muted", border: "border-muted", dot: "bg-muted" },
|
||||||
}[overallStatus]
|
}[overallStatus]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`bg-surface-card border rounded-lg overflow-hidden transition-all ${active ? "border-primary" : "border-hairline"}`}>
|
||||||
className={`bg-surface-card border-2 shadow-sm overflow-hidden transition-all ${
|
|
||||||
active ? "border-primary" : "border-surface-cream-strong"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between p-6 border-l-4 ${statusConfig.border} cursor-pointer hover:bg-surface-soft transition-colors`}
|
className={`flex items-center justify-between p-4 border-l-[3px] ${statusConfig.border} cursor-pointer hover:bg-surface-soft transition-colors`}
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="text-title-lg font-medium text-ink">{run.templateName}</h3>
|
<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`}>
|
<span className={`text-[10px] font-mono px-2 py-0.5 rounded uppercase ${statusConfig.bg} ${statusConfig.text} border border-current`}>
|
||||||
{statusConfig.label}
|
{statusConfig.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6 text-muted text-sm font-medium">
|
<div className="flex gap-4 text-muted text-sm">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="material-symbols-outlined text-base">calendar_today</span>
|
<span className="material-symbols-outlined text-base">calendar_today</span>
|
||||||
{new Date(run.createdAt).toLocaleDateString()}
|
{new Date(run.createdAt).toLocaleDateString()}
|
||||||
@@ -231,23 +210,17 @@ function BatchCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-8 mr-6">
|
<div className="flex items-center gap-6 mr-4 shrink-0">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-[10px] font-mono uppercase text-muted mb-1">Progress</p>
|
<p className="text-[10px] font-mono uppercase text-muted mb-0.5">Progress</p>
|
||||||
<p className="text-title-md font-medium text-ink">
|
<p className="text-title-sm font-medium text-ink">{completedCount}/{run.jobs.length || run.scannedPaths.length}</p>
|
||||||
{completedCount}/{run.jobs.length || run.scannedPaths.length}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{accuracy !== null && (
|
{accuracy !== null && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-[10px] font-mono uppercase text-muted mb-1">Accuracy</p>
|
<p className="text-[10px] font-mono uppercase text-muted mb-0.5">Accuracy</p>
|
||||||
<p className={`text-title-md font-medium ${statusConfig.text}`}>{accuracy.toFixed(1)}%</p>
|
<p className={`text-title-sm font-medium ${statusConfig.text}`}>{accuracy.toFixed(1)}%</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-[10px] font-mono uppercase text-muted mb-1">Speed</p>
|
|
||||||
<p className="text-title-md font-medium text-ink">{speed}<span className="text-sm text-muted">/sheet</span></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -255,70 +228,62 @@ function BatchCard({
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onToggle()
|
onToggle()
|
||||||
}}
|
}}
|
||||||
className="p-2 text-muted hover:text-ink hover:bg-surface-cream-strong transition-colors"
|
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>
|
<span className={`material-symbols-outlined transition-transform ${expanded ? "rotate-180" : ""}`}>keyboard_arrow_down</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="border-t-2 border-surface-cream-strong bg-canvas animate-fade-in">
|
<div className="border-t border-hairline bg-canvas animate-fade-in">
|
||||||
<div className="p-6">
|
<div className="p-4 space-y-5">
|
||||||
<div className="flex justify-between items-end mb-6">
|
<div className="flex justify-between items-end">
|
||||||
<div>
|
<div className="flex gap-3">
|
||||||
<h4 className="text-caption-uppercase text-muted uppercase tracking-wider mb-4">Sheet Inspection</h4>
|
<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">
|
||||||
<div className="flex gap-4">
|
<span className="material-symbols-outlined text-base">download</span>
|
||||||
<button className="bg-primary text-on-primary px-5 py-2 border-2 border-primary text-button hover:bg-primary-active active:scale-[0.98] transition-all flex items-center gap-2">
|
Export CSV
|
||||||
<span className="material-symbols-outlined text-base">download</span>
|
</button>
|
||||||
Export CSV
|
<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">
|
||||||
</button>
|
<span className="material-symbols-outlined text-base">picture_as_pdf</span>
|
||||||
<button className="text-primary border-2 border-primary px-5 py-2 text-button hover:bg-surface-card active:scale-[0.98] transition-all flex items-center gap-2">
|
Export PDF Report
|
||||||
<span className="material-symbols-outlined text-base">picture_as_pdf</span>
|
</button>
|
||||||
Export PDF Report
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
className="p-2 text-muted hover:text-error hover:bg-error/5 rounded transition-colors"
|
className="p-1.5 text-muted hover:text-error hover:bg-error/5 rounded-md transition-colors"
|
||||||
title="Remove run"
|
title="Remove run"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined">delete</span>
|
<span className="material-symbols-outlined">delete</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-8">
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
{run.jobs.slice(0, 8).map((job) => (
|
{run.jobs.slice(0, 12).map((job) => (
|
||||||
<JobThumbnail key={job.job_id} job={job} serverUrl={serverUrl} />
|
<JobThumbnail key={job.job_id} job={job} serverUrl={serverUrl} />
|
||||||
))}
|
))}
|
||||||
{run.jobs.length > 8 && (
|
{run.jobs.length > 12 && (
|
||||||
<button
|
<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">
|
||||||
onClick={() => {}}
|
<span className="material-symbols-outlined text-2xl text-muted">more_horiz</span>
|
||||||
className="bg-surface-soft border-2 border-dashed border-hairline flex flex-col items-center justify-center p-4 hover:bg-surface-card transition-all"
|
<span className="text-caption text-muted">+{run.jobs.length - 12} more</span>
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-2xl text-muted mb-1">more_horiz</span>
|
|
||||||
<span className="text-caption text-muted">+{run.jobs.length - 8} more</span>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto custom-scrollbar bg-surface-card border-2 border-surface-cream-strong">
|
<Table>
|
||||||
<table className="w-full text-left border-collapse">
|
<thead>
|
||||||
<thead>
|
<tr className="bg-canvas border-b border-hairline">
|
||||||
<tr className="bg-canvas border-b-2 border-surface-cream-strong">
|
<th className="p-3 text-[10px] font-mono uppercase text-muted">Sheet ID</th>
|
||||||
<th className="p-4 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-4 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-4 text-[10px] font-mono uppercase text-muted">Answers</th>
|
<th className="p-3 text-[10px] font-mono uppercase text-muted text-right">Actions</th>
|
||||||
<th className="p-4 text-[10px] font-mono uppercase text-muted text-right">Actions</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody className="font-mono text-code">
|
||||||
<tbody className="font-mono text-code">
|
{run.jobs.map((job) => (
|
||||||
{run.jobs.map((job) => (
|
<JobRow key={job.job_id} job={job} serverUrl={serverUrl} />
|
||||||
<JobRow key={job.job_id} job={job} serverUrl={serverUrl} />
|
))}
|
||||||
))}
|
</tbody>
|
||||||
</tbody>
|
</Table>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -337,10 +302,10 @@ function JobThumbnail({ job, serverUrl }: { job: JobStatusResponse; serverUrl: s
|
|||||||
const answers = extracted ? Object.keys(extracted).length : 0
|
const answers = extracted ? Object.keys(extracted).length : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative bg-surface-card border-2 border-surface-cream-strong p-2 cursor-pointer hover:border-primary transition-colors">
|
<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 overflow-hidden relative flex items-center justify-center">
|
<div className="aspect-[3/4] bg-canvas mb-2 rounded overflow-hidden relative flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<span className="material-symbols-outlined text-3xl text-muted">description</span>
|
<span className="material-symbols-outlined text-2xl text-muted">description</span>
|
||||||
<p className="text-[10px] font-mono text-muted mt-1">{answers} answers</p>
|
<p className="text-[10px] font-mono text-muted mt-1">{answers} answers</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
<div className="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
@@ -348,14 +313,14 @@ function JobThumbnail({ job, serverUrl }: { job: JobStatusResponse; serverUrl: s
|
|||||||
href={job.output_files?.[0] ? getOutputUrl(serverUrl, job.job_id, job.output_files[0]) : "#"}
|
href={job.output_files?.[0] ? getOutputUrl(serverUrl, job.job_id, job.output_files[0]) : "#"}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="bg-surface-card p-2 material-symbols-outlined text-primary"
|
className="bg-surface-card p-1.5 material-symbols-outlined text-primary rounded"
|
||||||
>
|
>
|
||||||
visibility
|
visibility
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] font-mono text-center truncate">{job.job_id.slice(0, 12)}...</p>
|
<p className="text-[10px] font-mono text-center truncate">{job.job_id.slice(0, 10)}...</p>
|
||||||
<span className={`absolute top-3 right-3 w-2 h-2 rounded-full ${statusColor}`} />
|
<span className={`absolute top-2 right-2 w-2 h-2 rounded-full ${statusColor}`} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -365,27 +330,14 @@ function JobRow({ job, serverUrl }: { job: JobStatusResponse; serverUrl: string
|
|||||||
const extracted = resultData?.ExtractedData as Record<string, unknown> | undefined
|
const extracted = resultData?.ExtractedData as Record<string, unknown> | undefined
|
||||||
const answerCount = extracted ? Object.keys(extracted).length : 0
|
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 (
|
return (
|
||||||
<tr className="border-b border-hairline hover:bg-surface-soft transition-colors">
|
<tr className="border-b border-hairline hover:bg-surface-soft transition-colors">
|
||||||
<td className="p-4">{job.job_id.slice(0, 20)}...</td>
|
<td className="p-3">{job.job_id.slice(0, 18)}...</td>
|
||||||
<td className="p-4">
|
<td className="p-3">
|
||||||
<span className={`flex items-center gap-2 ${statusColor}`}>
|
<StatusBadge status={job.status} />
|
||||||
<span className={`w-2 h-2 rounded-full ${statusDot}`} />
|
|
||||||
<span className="text-caption font-medium uppercase">{job.status}</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 text-muted-soft">{answerCount} extracted</td>
|
<td className="p-3 text-muted-soft">{answerCount} extracted</td>
|
||||||
<td className="p-4 text-right">
|
<td className="p-3 text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
{job.output_files?.map((file) => (
|
{job.output_files?.map((file) => (
|
||||||
<a
|
<a
|
||||||
@@ -407,3 +359,25 @@ function JobRow({ job, serverUrl }: { job: JobStatusResponse; serverUrl: string
|
|||||||
</tr>
|
</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, useEffect, useRef } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useApiStore } from "../../store/useApiStore"
|
import { useApiStore } from "../../store/useApiStore"
|
||||||
import { checkHealth } from "../../utils/apiClient"
|
import { checkHealth } from "../../utils/apiClient"
|
||||||
|
|
||||||
@@ -9,7 +9,6 @@ export function ServerSettings() {
|
|||||||
const [checking, setChecking] = useState(false)
|
const [checking, setChecking] = useState(false)
|
||||||
const [health, setHealth] = useState<{ ok: boolean; msg: string; data?: { version?: string; redis?: string; storage?: string; database?: 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(() => {
|
useEffect(() => {
|
||||||
setInputUrl(serverUrl)
|
setInputUrl(serverUrl)
|
||||||
}, [serverUrl])
|
}, [serverUrl])
|
||||||
@@ -44,146 +43,117 @@ export function ServerSettings() {
|
|||||||
? { label: "Error", color: "error", icon: "error" }
|
? { label: "Error", color: "error", icon: "error" }
|
||||||
: { label: "Unknown", color: "muted", icon: "help" }
|
: { 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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
{/* Page Header */}
|
<PageHeader title="Server Configuration" subtitle="Manage the connection parameters for your OMR backend and monitor infrastructure health." />
|
||||||
<div className="mb-10 border-b-2 border-surface-cream-strong pb-6">
|
|
||||||
<span className="text-caption-uppercase text-primary uppercase tracking-widest mb-2 block">Connection Profile</span>
|
<Panel title="Connection Profile" icon="link">
|
||||||
<h2 className="text-display-sm font-serif text-ink mb-2">Server Configuration</h2>
|
<div className="flex flex-col md:flex-row gap-3 items-end">
|
||||||
<p className="text-body-lg text-body max-w-2xl">
|
<div className="flex-1 w-full">
|
||||||
Manage the connection parameters for your OMR backend and monitor the health of critical infrastructure services.
|
<label className="text-caption text-muted block mb-1.5 font-medium">FastAPI Backend URL</label>
|
||||||
</p>
|
<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>
|
||||||
|
|
||||||
{/* Connection Profile Card */}
|
|
||||||
<section className="bg-surface-card border-2 border-surface-cream-strong p-6 shadow-sm relative overflow-hidden">
|
|
||||||
<div className="absolute top-0 left-0 w-1 h-full bg-primary" />
|
|
||||||
<div className="flex flex-col md:flex-row justify-between md:items-start gap-4 mb-8">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-title-lg font-medium text-primary mb-1">Connection Profile</h3>
|
|
||||||
<p className="text-body-md text-muted">Primary FastAPI Backend Endpoint</p>
|
|
||||||
</div>
|
|
||||||
<span className="bg-success/15 text-success px-3 py-1 text-caption font-medium rounded-full flex items-center gap-1.5 w-fit border border-success/20">
|
|
||||||
<span className="w-2 h-2 rounded-full bg-success animate-pulse" />
|
|
||||||
{status.label.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
|
||||||
<div className="md:col-span-3">
|
|
||||||
<label className="block text-caption-uppercase text-muted uppercase tracking-wider mb-2" htmlFor="backend-url">
|
|
||||||
FastAPI Backend URL
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
id="backend-url"
|
|
||||||
type="text"
|
|
||||||
value={inputUrl}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 material-symbols-outlined text-muted-soft">link</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={handleCheck}
|
|
||||||
disabled={checking}
|
|
||||||
className="w-full bg-primary text-on-primary py-[14px] px-6 text-button border-2 border-primary hover:bg-primary-active active:scale-[0.98] transition-all flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-[18px]">{checking ? "sync" : "bolt"}</span>
|
|
||||||
{checking ? "Connecting..." : "Test Connection"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 pt-8 border-t border-hairline grid grid-cols-1 md:grid-cols-3 gap-8">
|
|
||||||
{metrics.map((m) => (
|
|
||||||
<div key={m.label} className="flex flex-col">
|
|
||||||
<span className="text-caption-uppercase text-muted-soft uppercase tracking-wider mb-1">{m.label}</span>
|
|
||||||
<span className="text-title-md font-medium text-ink">{m.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Health Metrics */}
|
|
||||||
<section>
|
|
||||||
<h3 className="text-title-lg font-medium mb-6 px-2">Health Metrics</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<HealthCard
|
|
||||||
title="Redis Cluster"
|
|
||||||
subtitle="Cache & Messaging Layer"
|
|
||||||
status={health?.data?.redis || "unknown"}
|
|
||||||
uptime="99.9%"
|
|
||||||
icon="database"
|
|
||||||
/>
|
|
||||||
<HealthCard
|
|
||||||
title="File Storage"
|
|
||||||
subtitle="S3 / Local Object Store"
|
|
||||||
status={health?.data?.storage || "unknown"}
|
|
||||||
uptime="82% FULL"
|
|
||||||
icon="folder_open"
|
|
||||||
/>
|
|
||||||
<HealthCard
|
|
||||||
title="Main Database"
|
|
||||||
subtitle="Relational Records"
|
|
||||||
status={health?.data?.database || "unknown"}
|
|
||||||
uptime="PostgreSQL"
|
|
||||||
icon="storage"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{health && (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
(health.ok
|
|
||||||
? "text-success bg-success/10 border-success/20 "
|
|
||||||
: "text-error bg-error/10 border-error/20 ") +
|
|
||||||
"text-caption font-medium px-4 py-3 border rounded-md"
|
|
||||||
}
|
|
||||||
role={health.ok ? "status" : "alert"}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-[18px] align-middle mr-2">{status.icon}</span>
|
|
||||||
{health.msg}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HealthCard({ title, subtitle, status, uptime, icon }: {
|
function HealthCard({ title, subtitle, status, icon }: {
|
||||||
title: string
|
title: string
|
||||||
subtitle: string
|
subtitle: string
|
||||||
status: string
|
status: string
|
||||||
uptime: string
|
|
||||||
icon: string
|
icon: string
|
||||||
}) {
|
}) {
|
||||||
const online = status.toLowerCase() === "online" || status.toLowerCase() === "connected" || status.toLowerCase() === "active" || status.toLowerCase() === "ok"
|
const online = ["online", "connected", "active", "ok", "read/write ok", "active pool"].includes(status.toLowerCase())
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-card border-2 border-surface-cream-strong p-6 hover:bg-surface-soft hover:-translate-y-px active:scale-[0.99] transition-all shadow-sm">
|
<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-4">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="w-10 h-10 bg-canvas flex items-center justify-center border-2 border-hairline">
|
<div className="flex items-center gap-2">
|
||||||
<span className="material-symbols-outlined text-primary">{icon}</span>
|
<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>
|
||||||
<span className="text-[10px] font-mono bg-canvas px-2 py-1 border border-hairline uppercase text-muted">{uptime}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-title-sm font-medium mb-1">{title}</h4>
|
|
||||||
<p className="text-[11px] font-mono text-muted-soft mb-4">{subtitle}</p>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`w-2 h-2 rounded-full ${online ? "bg-success animate-pulse" : "bg-warning"}`} />
|
<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"}`}>
|
<span className={`text-caption font-medium uppercase ${online ? "text-success" : "text-warning"}`}>
|
||||||
{online ? "Connected" : status.toUpperCase()}
|
{status.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export function TemplateManager({ onNewJob }: TemplateManagerProps) {
|
|||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [editName, setEditName] = useState("")
|
const [editName, setEditName] = useState("")
|
||||||
const [filter, setFilter] = useState("")
|
const [filter, setFilter] = useState("")
|
||||||
|
|
||||||
const [showAddPanel, setShowAddPanel] = useState(false)
|
const [showAddPanel, setShowAddPanel] = useState(false)
|
||||||
|
|
||||||
const hasLoaded = useRef(false)
|
const hasLoaded = useRef(false)
|
||||||
@@ -54,7 +53,9 @@ export function TemplateManager({ onNewJob }: TemplateManagerProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredTemplates = templates.filter((t) => t.name.toLowerCase().includes(filter.toLowerCase()))
|
const filteredTemplates = templates.filter((t) =>
|
||||||
|
t.name.toLowerCase().includes(filter.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!templateDir) return
|
if (!templateDir) return
|
||||||
@@ -86,15 +87,11 @@ export function TemplateManager({ onNewJob }: TemplateManagerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
<header className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-8">
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-caption-uppercase text-primary uppercase tracking-widest mb-2 block">Library</span>
|
<h2 className="text-display-sm font-serif text-ink mb-1">Template Library</h2>
|
||||||
<h2 className="text-display-sm font-serif text-ink mb-2">Template Library</h2>
|
<p className="text-body-md text-muted max-w-md">Manage standardized OMR sheets and their coordinate mappings.</p>
|
||||||
<p className="text-body-lg text-body max-w-md">
|
|
||||||
Manage your standardized OMR sheets. Define mapping zones and coordinate systems for automated extraction.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -104,54 +101,51 @@ export function TemplateManager({ onNewJob }: TemplateManagerProps) {
|
|||||||
value={filter}
|
value={filter}
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
placeholder="Filter templates..."
|
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"
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddPanel(true)}
|
onClick={() => setShowAddPanel(true)}
|
||||||
className="bg-primary text-on-primary py-2 px-6 text-button border-2 border-primary hover:bg-primary-active active:scale-[0.98] transition-all flex items-center gap-2 shadow-sm"
|
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-sm">add</span>
|
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||||
Create Template
|
Create Template
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Directory */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{templateDir ? (
|
|
||||||
<div className="text-body-sm text-muted bg-canvas px-4 py-2 border-2 border-surface-cream-strong flex-1 truncate font-mono">
|
|
||||||
{templateDir}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-body-sm text-muted-soft bg-canvas px-4 py-2 border-2 border-surface-cream-strong flex-1 truncate">
|
|
||||||
No directory selected
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isElectron && (
|
|
||||||
<button
|
|
||||||
onClick={pickDirectory}
|
|
||||||
className="px-4 py-2 bg-surface-card border-2 border-hairline text-button text-ink hover:bg-surface-cream-strong active:scale-[0.98] transition-all h-10 shrink-0"
|
|
||||||
>
|
|
||||||
{templateDir ? "Change" : "Choose Directory"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isElectron && (
|
<Panel title="Storage Directory" icon="folder">
|
||||||
<div className="text-caption text-warning bg-warning/10 px-4 py-2 border border-warning/20 font-medium rounded-md">
|
<div className="flex items-center gap-2">
|
||||||
Running in web mode. Templates will not persist across reloads.
|
{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>
|
</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 && (
|
{message && (
|
||||||
<div className="text-caption text-body bg-canvas border-2 border-hairline px-4 py-2 rounded-md font-medium">
|
<div className="text-caption text-body bg-canvas border border-hairline px-3 py-2 rounded-md font-medium">{message}</div>
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Template Grid */}
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{filteredTemplates.map((t) => (
|
{filteredTemplates.map((t) => (
|
||||||
<TemplateCard
|
<TemplateCard
|
||||||
key={t.id}
|
key={t.id}
|
||||||
@@ -166,18 +160,10 @@ export function TemplateManager({ onNewJob }: TemplateManagerProps) {
|
|||||||
onUse={() => onNewJob?.()}
|
onUse={() => onNewJob?.()}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddPanel(true)}
|
|
||||||
className="border-2 border-dashed border-surface-cream-strong flex flex-col items-center justify-center p-8 hover:bg-surface-soft hover:border-primary active:scale-[0.98] transition-all min-h-[280px]"
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-display-md text-hairline group-hover:text-primary mb-4">add_circle</span>
|
|
||||||
<span className="text-title-sm text-muted">Add New Template</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredTemplates.length === 0 && !loading && (
|
{filteredTemplates.length === 0 && !loading && (
|
||||||
<div className="text-caption text-muted px-4 py-8 border-2 border-dashed border-hairline bg-canvas text-center rounded-md">
|
<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.
|
No templates found. Create one to get started.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -210,14 +196,10 @@ function TemplateCard({
|
|||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onUse: () => void
|
onUse: () => void
|
||||||
}) {
|
}) {
|
||||||
const status = "READY"
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-card border-2 border-surface-cream-strong p-4 flex flex-col group hover:bg-surface-soft hover:-translate-y-px active:scale-[0.99] transition-all">
|
<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 border-2 border-hairline overflow-hidden">
|
<div className="relative aspect-[3/4] bg-canvas mb-4 rounded-md border border-hairline overflow-hidden flex items-center justify-center">
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<TemplatePreview template={template} />
|
||||||
<TemplatePreview template={template} />
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-0 bg-primary/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
@@ -231,18 +213,18 @@ function TemplateCard({
|
|||||||
if (e.key === "Escape") onCancelEdit()
|
if (e.key === "Escape") onCancelEdit()
|
||||||
}}
|
}}
|
||||||
autoFocus
|
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"
|
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">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onSaveEdit}
|
onClick={onSaveEdit}
|
||||||
className="flex-1 px-3 py-1.5 bg-primary text-on-primary text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all"
|
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
|
Save
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onCancelEdit}
|
onClick={onCancelEdit}
|
||||||
className="flex-1 px-3 py-1.5 border-2 border-hairline bg-surface-soft text-button text-ink hover:bg-surface-cream-strong active:scale-[0.98] transition-all"
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -250,11 +232,11 @@ function TemplateCard({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-1">
|
||||||
<h3 className="text-title-md font-medium text-ink">{template.name}</h3>
|
<h3 className="text-title-md font-medium text-ink truncate">{template.name}</h3>
|
||||||
<span className="text-[10px] font-mono bg-canvas px-2 py-1 uppercase border border-hairline text-muted">V.1.0</span>
|
<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>
|
</div>
|
||||||
<p className="text-caption text-muted mb-4">
|
<p className="text-caption text-muted mb-3">
|
||||||
{template.bubbleTemplatePath && template.filledTemplatePath
|
{template.bubbleTemplatePath && template.filledTemplatePath
|
||||||
? "Bubble + Filled"
|
? "Bubble + Filled"
|
||||||
: template.bubbleTemplatePath
|
: template.bubbleTemplatePath
|
||||||
@@ -269,32 +251,29 @@ function TemplateCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!editing && (
|
{!editing && (
|
||||||
<div className="mt-auto pt-4 border-t-2 border-surface-cream-strong flex justify-between items-center">
|
<div className="mt-auto pt-3 border-t border-hairline flex justify-between items-center">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={onStartEdit}
|
onClick={onStartEdit}
|
||||||
className="p-1.5 text-muted hover:text-primary hover:bg-primary/5 rounded transition-all"
|
className="p-1.5 rounded-md text-muted hover:text-primary hover:bg-primary/5 transition-all"
|
||||||
title="Edit name"
|
title="Edit name"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
className="p-1.5 text-muted hover:text-error hover:bg-error/5 rounded transition-all"
|
className="p-1.5 rounded-md text-muted hover:text-error hover:bg-error/5 transition-all"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
<button
|
onClick={onUse}
|
||||||
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"
|
||||||
className="text-caption text-primary hover:underline font-medium"
|
>
|
||||||
>
|
Use in Job
|
||||||
Use in job
|
</button>
|
||||||
</button>
|
|
||||||
<span className="text-[10px] font-mono text-success uppercase">{status}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -328,7 +307,7 @@ function TemplatePreview({ template }: { template: TemplateEntry }) {
|
|||||||
if (!src) {
|
if (!src) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center text-muted-soft">
|
<div className="flex flex-col items-center text-muted-soft">
|
||||||
<span className="material-symbols-outlined text-4xl mb-2">image</span>
|
<span className="material-symbols-outlined text-3xl mb-1">image</span>
|
||||||
<span className="text-[10px] font-mono uppercase">No Preview</span>
|
<span className="text-[10px] font-mono uppercase">No Preview</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -472,53 +451,44 @@ function AddTemplatePanel({ open, onClose }: { open: boolean; onClose: () => voi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50">
|
<div className="fixed inset-0 z-50">
|
||||||
<div
|
<div className="absolute inset-0 bg-surface-dark/40 backdrop-blur-sm" onClick={onClose} />
|
||||||
className="absolute inset-0 bg-surface-dark/40 backdrop-blur-sm"
|
<div className="absolute right-0 top-0 bottom-0 w-full max-w-md bg-surface-card shadow-2xl flex flex-col">
|
||||||
onClick={onClose}
|
<div className="p-5 border-b border-hairline flex justify-between items-center bg-canvas">
|
||||||
/>
|
|
||||||
<div className="absolute right-0 top-0 bottom-0 w-full max-w-lg bg-surface-card shadow-2xl flex flex-col">
|
|
||||||
<div className="p-8 border-b-2 border-surface-cream-strong flex justify-between items-center bg-canvas">
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-display-sm font-serif text-ink">New Template</h2>
|
<h2 className="text-title-lg font-medium text-ink">New Template</h2>
|
||||||
<p className="text-caption text-muted mt-1">Define extraction parameters</p>
|
<p className="text-caption text-muted">Define extraction parameters</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={onClose} className="p-2 text-muted hover:text-ink hover:bg-surface-soft rounded-md transition-colors">
|
||||||
onClick={onClose}
|
<span className="material-symbols-outlined">close</span>
|
||||||
className="material-symbols-outlined p-2 hover:bg-surface-soft rounded transition-colors"
|
|
||||||
>
|
|
||||||
close
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-8 space-y-8 custom-scrollbar">
|
<div className="flex-1 overflow-y-auto p-5 space-y-5 custom-scrollbar">
|
||||||
<div className="space-y-2">
|
<Field label="Template Name">
|
||||||
<label className="block text-caption-uppercase text-muted uppercase tracking-wider">Template Name</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g. Q4 Performance Survey"
|
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"
|
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>
|
</Field>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-caption-uppercase text-muted uppercase tracking-wider">Reference Blank Image</label>
|
<label className="text-caption text-muted font-medium">Reference Blank Image</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => selectFile("image/png,image/jpeg", setTemplateFile)}
|
onClick={() => selectFile("image/png,image/jpeg", setTemplateFile)}
|
||||||
className={`w-full border-2 border-dashed border-surface-cream-strong p-12 text-center hover:bg-surface-soft transition-colors ${templateFile ? "bg-primary/5 border-primary/30" : "bg-canvas"}`}
|
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-4xl text-muted mb-4 block">upload_file</span>
|
<span className="material-symbols-outlined text-3xl text-muted mb-2 block">upload_file</span>
|
||||||
<p className="text-body-md text-ink">
|
<p className="text-body-sm text-ink">{templateFile ? templateFile.name : "Drop blank scan or browse"}</p>
|
||||||
{templateFile ? templateFile.name : "Drop blank scan or browse"}
|
{!templateFile && <p className="text-[10px] font-mono text-muted mt-1">MAX 10MB • PNG, JPG</p>}
|
||||||
</p>
|
|
||||||
{!templateFile && <p className="text-[10px] font-mono text-muted mt-2">MAX 10MB • PNG, JPG</p>}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label className="block text-caption-uppercase text-muted uppercase tracking-wider">JSON Configuration</label>
|
<label className="text-caption text-muted font-medium">JSON Configuration</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => selectFile("application/json", handleMetadataFile)}
|
onClick={() => selectFile("application/json", handleMetadataFile)}
|
||||||
className="text-[10px] font-mono text-primary hover:underline uppercase"
|
className="text-[10px] font-mono text-primary hover:underline uppercase"
|
||||||
@@ -526,66 +496,49 @@ function AddTemplatePanel({ open, onClose }: { open: boolean; onClose: () => voi
|
|||||||
Upload .JSON
|
Upload .JSON
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative group">
|
<textarea
|
||||||
<textarea
|
value={metadataText}
|
||||||
value={metadataText}
|
onChange={(e) => setMetadataText(e.target.value)}
|
||||||
onChange={(e) => setMetadataText(e.target.value)}
|
placeholder='{
|
||||||
placeholder='{
|
|
||||||
"template_id": "auto_gen",
|
"template_id": "auto_gen",
|
||||||
"fields": [
|
"fields": [
|
||||||
{ "id": "q1", "type": "bubble", "coords": [120, 450] }
|
{ "id": "q1", "type": "bubble", "coords": [120, 450] }
|
||||||
]
|
]
|
||||||
}'
|
}'
|
||||||
rows={10}
|
rows={8}
|
||||||
className="w-full border-2 border-surface-cream-strong p-4 font-mono text-code bg-surface-dark text-on-dark focus:border-primary focus:ring-0 custom-scrollbar"
|
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 className="absolute top-2 right-2 flex gap-2">
|
|
||||||
<span className="material-symbols-outlined text-xs text-on-dark-soft cursor-help" title="Valid JSON required">info</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button
|
<FileChip
|
||||||
|
label="Bubble Template"
|
||||||
|
filename={bubbleTemplateFile?.name}
|
||||||
onClick={() => selectFile("image/png,image/jpeg", setBubbleTemplateFile)}
|
onClick={() => selectFile("image/png,image/jpeg", setBubbleTemplateFile)}
|
||||||
className={`border-2 p-4 text-left transition-all ${
|
/>
|
||||||
bubbleTemplateFile
|
<FileChip
|
||||||
? "border-primary bg-primary/5"
|
label="Filled Bubble"
|
||||||
: "border-surface-cream-strong bg-surface-card hover:bg-surface-soft"
|
filename={filledTemplateFile?.name}
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="text-caption font-bold mb-1">Bubble Template</p>
|
|
||||||
<p className="text-[11px] text-muted">{bubbleTemplateFile ? bubbleTemplateFile.name : "Optional clean bubble"}</p>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => selectFile("image/png,image/jpeg", setFilledTemplateFile)}
|
onClick={() => selectFile("image/png,image/jpeg", setFilledTemplateFile)}
|
||||||
className={`border-2 p-4 text-left transition-all ${
|
/>
|
||||||
filledTemplateFile
|
|
||||||
? "border-primary bg-primary/5"
|
|
||||||
: "border-surface-cream-strong bg-surface-card hover:bg-surface-soft"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="text-caption font-bold mb-1">Filled Bubble</p>
|
|
||||||
<p className="text-[11px] text-muted">{filledTemplateFile ? filledTemplateFile.name : "Optional filled bubble"}</p>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-caption text-error bg-error/10 px-4 py-2 border border-error/20 rounded-md">{error}</div>
|
<div className="text-caption text-error bg-error/10 px-3 py-2 rounded-md border border-error/20">{error}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 border-t-2 border-surface-cream-strong bg-canvas flex gap-4">
|
<div className="p-5 border-t border-hairline bg-canvas flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="flex-1 bg-primary text-on-primary text-button font-bold py-4 border-2 border-primary hover:bg-primary-active active:scale-[0.98] transition-all disabled:bg-primary-disabled disabled:text-muted"
|
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"}
|
{saving ? "Saving..." : "Save Template"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 border-2 border-primary text-primary text-button font-bold py-4 hover:bg-surface-card active:scale-[0.98] transition-all"
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -594,3 +547,38 @@ function AddTemplatePanel({ open, onClose }: { open: boolean; onClose: () => voi
|
|||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user