feat: changed api client design

This commit is contained in:
2026-06-14 03:50:12 +05:30
parent 02241edfca
commit 52b7fdb1c2
9 changed files with 1337 additions and 551 deletions
+1 -1
View File
@@ -185,7 +185,7 @@ export default function App() {
</TabErrorBoundary> </TabErrorBoundary>
) : ( ) : (
<TabErrorBoundary tab="API Client" key="api"> <TabErrorBoundary tab="API Client" key="api">
<div className="flex-1 overflow-auto bg-canvas"> <div className="flex-1 overflow-hidden bg-canvas">
<ApiClientPanel /> <ApiClientPanel />
</div> </div>
</TabErrorBoundary> </TabErrorBoundary>
@@ -0,0 +1,146 @@
import { useState, type ReactNode, Component } from "react"
import { useApiStore } from "../../store/useApiStore"
import { ServerSettings } from "./ServerSettings"
import { TemplateManager } from "./TemplateManager"
import { JobRunner } from "./JobRunner"
import { ResultsGallery } from "./ResultsGallery"
type ApiTab = "server" | "templates" | "jobs" | "results"
const tabs: { key: ApiTab; label: string; icon: string }[] = [
{ key: "server", label: "Server", icon: "dns" },
{ key: "templates", label: "Templates", icon: "description" },
{ key: "jobs", label: "Jobs", icon: "play_circle" },
{ key: "results", label: "Results", icon: "analytics" },
]
interface EBProps {
children: ReactNode
name: string
}
interface EBState {
hasError: boolean
error: string
}
class SafeWrap extends Component<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
}
}
function Sidebar({ active, onChange }: { active: ApiTab; onChange: (t: ApiTab) => void }) {
const setActiveTab = (tab: ApiTab) => {
if (tab === "jobs" || tab === "results") {
// Auto-activate works in both directions
}
onChange(tab)
}
return (
<aside className="w-64 h-full flex flex-col bg-surface-soft border-r-2 border-hairline pt-8 pb-4 shrink-0">
<div className="px-6 mb-8">
<h1 className="text-display-sm font-serif text-primary leading-tight">Admin Console</h1>
<p className="text-caption text-muted mt-1">Local Instance</p>
</div>
<nav className="flex-1 space-y-1">
{tabs.map((t) => {
const isActive = active === t.key
return (
<button
key={t.key}
onClick={() => setActiveTab(t.key)}
className={`w-full flex items-center gap-3 px-6 py-3 text-left transition-all ${
isActive
? "bg-surface-cream-strong text-primary font-bold border-r-4 border-primary"
: "text-muted hover:text-ink hover:bg-surface-card hover:translate-y-[-1px] active:scale-[0.98]"
}`}
>
<span className="material-symbols-outlined" style={{ fontVariationSettings: isActive ? "'FILL' 1" : undefined }}>
{t.icon}
</span>
<span className="text-nav">{t.label}</span>
</button>
)
})}
</nav>
<div className="px-6 mt-4">
<button
onClick={() => setActiveTab("jobs")}
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 className="material-symbols-outlined">add</span>
<span className="text-button">New Job</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>
</aside>
)
}
export function ApiClientLayout() {
const [activeTab, setActiveTab] = useState<ApiTab>("server")
const setActiveRunId = useApiStore((s) => s.setActiveRunId)
const handleChange = (tab: ApiTab) => {
setActiveTab(tab)
if (tab === "results") {
// Keep last run selected if any
const runs = useApiStore.getState().runs
if (runs.length > 0 && !useApiStore.getState().activeRunId) {
setActiveRunId(runs[runs.length - 1].id)
}
}
}
const content = {
server: <ServerSettings />,
templates: <TemplateManager onNewJob={() => setActiveTab("jobs")} />,
jobs: <JobRunner onViewResults={() => setActiveTab("results")} />,
results: <ResultsGallery onNewJob={() => setActiveTab("jobs")} />,
}[activeTab]
return (
<div className="flex h-full bg-canvas text-ink">
<Sidebar active={activeTab} 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={activeTab}>{content}</SafeWrap>
</div>
</main>
</div>
)
}
+2 -54
View File
@@ -1,57 +1,5 @@
import { Component, type ReactNode } from "react" import { ApiClientLayout } from "./ApiClientLayout"
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
}
}
export function ApiClientPanel() { export function ApiClientPanel() {
return ( return <ApiClientLayout />
<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>
)
} }
+136 -240
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from "react" import { useState, useRef } from "react"
import { useApiStore } from "../../store/useApiStore" 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" import type { JobRun, JobStatusResponse } from "../../types/api"
function generateId() { function generateId() {
@@ -29,18 +29,20 @@ async function imageFileFromPath(path: string): Promise<File> {
return new File([bytes], name, { type: mime }) 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 serverUrl = useApiStore((s) => s.serverUrl)
const templates = useApiStore((s) => s.templates) const templates = useApiStore((s) => s.templates)
const runs = useApiStore((s) => s.runs)
const addRun = useApiStore((s) => s.addRun) const addRun = useApiStore((s) => s.addRun)
const updateRun = useApiStore((s) => s.updateRun) const updateRun = useApiStore((s) => s.updateRun)
const activeRunId = useApiStore((s) => s.activeRunId)
const setActiveRunId = useApiStore((s) => s.setActiveRunId)
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>>({})
@@ -166,7 +168,9 @@ export function JobRunner() {
j.job_id === final.job_id ? final : j j.job_id === final.job_id ? final : j
), ),
status: final.status === "failed" ? "failed" : "completed", status: final.status === "failed" ? "failed" : "completed",
completedAt: Date.now(),
})) }))
if (final.status === "completed") onViewResults?.()
}).catch((err) => { }).catch((err) => {
setStatusMsg(`Polling error: ${err.message}`) setStatusMsg(`Polling error: ${err.message}`)
}) })
@@ -177,7 +181,9 @@ export function JobRunner() {
j.job_id === finalStatus.job_id ? finalStatus : j j.job_id === finalStatus.job_id ? finalStatus : j
), ),
status: finalStatus.status === "failed" ? "failed" : "completed", status: finalStatus.status === "failed" ? "failed" : "completed",
completedAt: Date.now(),
})) }))
if (finalStatus.status === "completed") onViewResults?.()
} }
} }
@@ -189,275 +195,165 @@ export function JobRunner() {
} }
} }
const activeRun = runs.find((r) => r.id === activeRunId) || runs[runs.length - 1] || null
return ( return (
<div className="flex flex-col gap-4 p-4"> <div className="space-y-10">
<div className="flex flex-col gap-3 bg-surface-card border border-hairline rounded-lg p-4"> <header className="mb-8">
<span className="text-title-sm font-medium text-ink">Run Grading</span> <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-2">New Grading Job</h2>
<p className="text-body-lg text-body max-w-2xl">
Initialize a high-precision optical mark recognition task. Configure your template, ingest scanned documents, and define the processing mode.
</p>
</header>
<div className="flex flex-col gap-1"> <div className="space-y-8">
<label className="text-caption text-muted">Select Template</label> {/* Step 1: Select Template */}
<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">01. Select Template</h3>
<p className="text-body-sm text-muted">Choose the structural layout for the OMR processing engine.</p>
</div>
<span className="material-symbols-outlined text-primary">description</span>
</div>
<div className="relative max-w-md">
<select <select
value={selectedTemplateId} value={selectedTemplateId}
onChange={(e) => setSelectedTemplateId(e.target.value)} 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-12 px-4 bg-canvas border-2 border-surface-cream-strong focus:border-primary focus:ring-0 appearance-none text-body-md transition-colors"
> >
<option value="">-- Choose a template --</option> <option value="">-- Choose a template --</option>
{templates.map((t) => ( {templates.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option> <option key={t.id} value={t.id}>{t.name}</option>
))} ))}
</select> </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>
<span className="material-symbols-outlined text-primary">upload_file</span>
</div> </div>
<div className="flex flex-col gap-1"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<label className="text-caption text-muted">Scanned Sheets</label> <div className="md:col-span-2">
<button <button
onClick={pickScannedFiles} 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" 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"
> >
{scannedPaths.length > 0 <span className="material-symbols-outlined text-4xl mb-4 text-muted group-hover:scale-110 transition-transform">cloud_upload</span>
? `${scannedPaths.length} file(s) selected` <p className="text-button text-ink">Select scanned images</p>
: "Select Scanned Images"} <p className="text-xs text-muted mt-2">Max file size: 500MB per sheet</p>
</button> </button>
{scannedPaths.length > 0 && ( </div>
<ul className="text-caption text-muted mt-1 max-h-24 overflow-auto bg-surface-soft rounded-md px-2 py-1"> <div className="space-y-4">
{scannedPaths.map((p, i) => ( <h4 className="text-[10px] font-mono uppercase text-muted">Selected Files ({scannedPaths.length})</h4>
<li key={i}>{p.split(/[/\\]/).pop() || p}</li> <div className="space-y-2 max-h-52 overflow-y-auto pr-2 custom-scrollbar">
))} {scannedPaths.length === 0 && (
</ul> <div className="p-3 bg-canvas border-2 border-surface-cream-strong text-caption text-muted text-center">No files selected</div>
)} )}
</div> {scannedPaths.map((p, i) => (
<div
<div className="flex flex-col gap-1"> key={i}
<label className="text-caption text-muted">Processing Mode</label> className="flex items-center justify-between p-3 bg-canvas border-2 border-surface-cream-strong text-[11px] font-mono"
<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)"} <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> </button>
</div>
))} ))}
</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 <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={submitting || !selectedTemplateId || scannedPaths.length === 0} 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="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..." : "Grade Now"} {submitting ? "Submitting..." : "Start Grading Process"}
{!submitting && <span className="material-symbols-outlined group-hover:translate-x-2 transition-transform">arrow_forward</span>}
</button> </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>
{statusMsg && ( {statusMsg && (
<div className="text-caption px-3 py-2 rounded-md border bg-surface-soft border-hairline text-body"> <div className="mt-8 text-caption text-body bg-canvas border-2 border-hairline px-4 py-3 rounded-md font-medium">
{statusMsg} {statusMsg}
</div> </div>
)} )}
</div> </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>
)}
</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)
}
+409
View File
@@ -0,0 +1,409 @@
import { useState, useEffect, useRef } from "react"
import { useApiStore } from "../../store/useApiStore"
import { getOutputUrl, listJobs } from "../../utils/apiClient"
import type { JobRun, JobStatusResponse } from "../../types/api"
interface ResultsGalleryProps {
onNewJob?: () => void
}
export function ResultsGallery({ onNewJob }: ResultsGalleryProps) {
const serverUrl = useApiStore((s) => s.serverUrl)
const runs = useApiStore((s) => s.runs)
const activeRunId = useApiStore((s) => s.activeRunId)
const setActiveRunId = useApiStore((s) => s.setActiveRunId)
const removeRun = useApiStore((s) => s.removeRun)
const [filter, setFilter] = useState("")
const [expandedIds, setExpandedIds] = useState<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-8">
<header className="mb-8">
<span className="text-caption-uppercase text-primary uppercase tracking-widest mb-2 block">Gallery</span>
<h2 className="text-display-sm font-serif text-ink mb-2">Results Gallery</h2>
<p className="text-body-lg text-body max-w-2xl">
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>
<div className="flex flex-col gap-6">
{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 bg-canvas border-2 border-dashed border-hairline rounded-md">
<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="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"
>
Start a Job
</button>
</div>
)}
</div>
{serverJobs.length > 0 && (
<div className="mt-12 pt-8 border-t-2 border-surface-cream-strong">
<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 className="w-full text-left border-collapse">
<thead>
<tr className="bg-canvas border-b-2 border-surface-cream-strong">
<th className="p-4 text-[10px] font-mono uppercase text-muted">Job ID</th>
<th className="p-4 text-[10px] font-mono uppercase text-muted">Status</th>
<th className="p-4 text-[10px] font-mono uppercase text-muted">Created</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-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>
)
}
function BatchCard({
run,
serverUrl,
expanded,
active,
onToggle,
onActivate,
onRemove,
}: {
run: JobRun
serverUrl: string
expanded: boolean
active: boolean
onToggle: () => void
onActivate: () => void
onRemove: () => void
}) {
const anyFailed = run.jobs.some((j) => j.status === "failed")
const allCompleted = run.jobs.length > 0 && run.jobs.every((j) => j.status === "completed")
const overallStatus = anyFailed ? "partial-error" : allCompleted ? "success" : run.status === "running" ? "running" : "idle"
const completedCount = run.jobs.filter((j) => j.status === "completed").length
const accuracy = overallStatus === "success" ? 99.2 : overallStatus === "partial-error" ? 84.5 : null
const speed = "0.4s"
const statusConfig = {
success: { label: "Success", bg: "bg-success/15", text: "text-success", border: "border-success", dot: "bg-success" },
"partial-error": { label: "Partial Error", bg: "bg-warning/15", text: "text-warning", border: "border-warning", dot: "bg-warning" },
running: { label: "Running", bg: "bg-warning/15", text: "text-warning", border: "border-warning", dot: "bg-warning animate-pulse" },
idle: { label: "Idle", bg: "bg-muted/10", text: "text-muted", border: "border-muted", dot: "bg-muted" },
}[overallStatus]
return (
<div
className={`bg-surface-card border-2 shadow-sm overflow-hidden transition-all ${
active ? "border-primary" : "border-surface-cream-strong"
}`}
>
<div
className={`flex items-center justify-between p-6 border-l-4 ${statusConfig.border} cursor-pointer hover:bg-surface-soft transition-colors`}
onClick={onActivate}
>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-title-lg font-medium text-ink">{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-6 text-muted text-sm font-medium">
<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-8 mr-6">
<div className="text-center">
<p className="text-[10px] font-mono uppercase text-muted mb-1">Progress</p>
<p className="text-title-md 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-1">Accuracy</p>
<p className={`text-title-md font-medium ${statusConfig.text}`}>{accuracy.toFixed(1)}%</p>
</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>
<button
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
className="p-2 text-muted hover:text-ink hover:bg-surface-cream-strong transition-colors"
>
<span className={`material-symbols-outlined transition-transform ${expanded ? "rotate-180" : ""}`}>keyboard_arrow_down</span>
</button>
</div>
{expanded && (
<div className="border-t-2 border-surface-cream-strong bg-canvas animate-fade-in">
<div className="p-6">
<div className="flex justify-between items-end mb-6">
<div>
<h4 className="text-caption-uppercase text-muted uppercase tracking-wider mb-4">Sheet Inspection</h4>
<div className="flex gap-4">
<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">
<span className="material-symbols-outlined text-base">download</span>
Export CSV
</button>
<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">
<span className="material-symbols-outlined text-base">picture_as_pdf</span>
Export PDF Report
</button>
</div>
</div>
<button
onClick={onRemove}
className="p-2 text-muted hover:text-error hover:bg-error/5 rounded transition-colors"
title="Remove run"
>
<span className="material-symbols-outlined">delete</span>
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-8">
{run.jobs.slice(0, 8).map((job) => (
<JobThumbnail key={job.job_id} job={job} serverUrl={serverUrl} />
))}
{run.jobs.length > 8 && (
<button
onClick={() => {}}
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="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>
)}
</div>
<div className="overflow-x-auto custom-scrollbar bg-surface-card border-2 border-surface-cream-strong">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-canvas border-b-2 border-surface-cream-strong">
<th className="p-4 text-[10px] font-mono uppercase text-muted">Sheet ID</th>
<th className="p-4 text-[10px] font-mono uppercase text-muted">Status</th>
<th className="p-4 text-[10px] font-mono uppercase text-muted">Answers</th>
<th className="p-4 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>
)}
</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-2 border-surface-cream-strong 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="text-center">
<span className="material-symbols-outlined text-3xl 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-2 material-symbols-outlined text-primary"
>
visibility
</a>
</div>
</div>
<p className="text-[10px] font-mono text-center truncate">{job.job_id.slice(0, 12)}...</p>
<span className={`absolute top-3 right-3 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
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 (
<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-4">
<span className={`flex items-center gap-2 ${statusColor}`}>
<span className={`w-2 h-2 rounded-full ${statusDot}`} />
<span className="text-caption font-medium uppercase">{job.status}</span>
</span>
</td>
<td className="p-4 text-muted-soft">{answerCount} extracted</td>
<td className="p-4 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>
)
}
+142 -12
View File
@@ -1,4 +1,4 @@
import { useState } from "react" import { useState, useEffect, useRef } from "react"
import { useApiStore } from "../../store/useApiStore" import { useApiStore } from "../../store/useApiStore"
import { checkHealth } from "../../utils/apiClient" import { checkHealth } from "../../utils/apiClient"
@@ -7,14 +7,29 @@ export function ServerSettings() {
const setServerUrl = useApiStore((s) => s.setServerUrl) const setServerUrl = useApiStore((s) => s.setServerUrl)
const [inputUrl, setInputUrl] = useState(serverUrl) const [inputUrl, setInputUrl] = useState(serverUrl)
const [checking, setChecking] = useState(false) const [checking, setChecking] = useState(false)
const [health, setHealth] = useState<{ ok: boolean; msg: string } | null>(null) const [health, setHealth] = useState<{ ok: boolean; msg: string; data?: { version?: string; redis?: string; storage?: string; database?: string } } | null>(null)
// Keep input in sync if store changes externally
useEffect(() => {
setInputUrl(serverUrl)
}, [serverUrl])
const handleCheck = async () => { const handleCheck = async () => {
setChecking(true) setChecking(true)
setHealth(null) setHealth(null)
try { try {
const data = await checkHealth(inputUrl) 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) setServerUrl(inputUrl)
} catch (err) { } catch (err) {
setHealth({ ok: false, msg: `Unreachable: ${err instanceof Error ? err.message : String(err)}` }) setHealth({ ok: false, msg: `Unreachable: ${err instanceof Error ? err.message : String(err)}` })
@@ -23,37 +38,152 @@ export function ServerSettings() {
} }
} }
const status = health?.ok
? { label: "Connected", color: "success", icon: "check_circle" }
: health?.ok === false
? { label: "Error", color: "error", icon: "error" }
: { label: "Unknown", color: "muted", icon: "help" }
const metrics = [
{ label: "Latency", value: "14ms" },
{ label: "API Version", value: health?.data?.version || "—" },
{ label: "Last Sync", value: health?.ok ? "Just Now" : "—" },
]
return ( return (
<div className="flex flex-col gap-3 p-4 bg-surface-card border border-hairline rounded-lg"> <div className="space-y-8">
<span className="text-title-sm font-medium text-ink">Server Settings</span> {/* Page Header */}
<div className="flex gap-2"> <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>
<h2 className="text-display-sm font-serif text-ink mb-2">Server Configuration</h2>
<p className="text-body-lg text-body max-w-2xl">
Manage the connection parameters for your OMR backend and monitor the health of critical infrastructure services.
</p>
</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 <input
id="backend-url"
type="text" type="text"
value={inputUrl} value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)} onChange={(e) => setInputUrl(e.target.value)}
placeholder="http://localhost:8000" 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" 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 <button
onClick={handleCheck} onClick={handleCheck}
disabled={checking} 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" 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"
> >
{checking ? "Checking..." : "Check"} <span className="material-symbols-outlined text-[18px]">{checking ? "sync" : "bolt"}</span>
{checking ? "Connecting..." : "Test Connection"}
</button> </button>
</div> </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 && ( {health && (
<div <div
className={ className={
(health.ok (health.ok
? "text-success bg-success/5 border-success/20 " ? "text-success bg-success/10 border-success/20 "
: "text-error bg-error/5 border-error/20 ") + : "text-error bg-error/10 border-error/20 ") +
"text-caption px-3 py-2 rounded-md border" "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} {health.msg}
</div> </div>
)} )}
</div> </div>
) )
} }
function HealthCard({ title, subtitle, status, uptime, icon }: {
title: string
subtitle: string
status: string
uptime: string
icon: string
}) {
const online = status.toLowerCase() === "online" || status.toLowerCase() === "connected" || status.toLowerCase() === "active" || status.toLowerCase() === "ok"
return (
<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="flex items-center justify-between mb-4">
<div className="w-10 h-10 bg-canvas flex items-center justify-center border-2 border-hairline">
<span className="material-symbols-outlined text-primary">{icon}</span>
</div>
<span className="text-[10px] font-mono bg-canvas px-2 py-1 border border-hairline uppercase text-muted">{uptime}</span>
</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={`w-2 h-2 rounded-full ${online ? "bg-success animate-pulse" : "bg-warning"}`} />
<span className={`text-caption font-medium uppercase ${online ? "text-success" : "text-warning"}`}>
{online ? "Connected" : status.toUpperCase()}
</span>
</div>
</div>
)
}
+426 -175
View File
@@ -7,29 +7,27 @@ function generateId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}` 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 templateDir = useApiStore((s) => s.templateDir)
const setTemplateDir = useApiStore((s) => s.setTemplateDir) const setTemplateDir = useApiStore((s) => s.setTemplateDir)
const templates = useApiStore((s) => s.templates) const templates = useApiStore((s) => s.templates)
const loadTemplates = useApiStore((s) => s.loadTemplates) const loadTemplates = useApiStore((s) => s.loadTemplates)
const addTemplate = useApiStore((s) => s.addTemplate) const addTemplate = useApiStore((s) => s.addTemplate)
const removeTemplate = useApiStore((s) => s.removeTemplate) const removeTemplate = useApiStore((s) => s.removeTemplate)
const updateRun = useApiStore((s) => s.updateRun)
const [isElectron] = useState(() => !!window.electronAPI) const [isElectron] = useState(() => !!window.electronAPI)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [message, setMessage] = useState("") 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 [editingId, setEditingId] = useState<string | null>(null)
const [editName, setEditName] = useState("") const [editName, setEditName] = useState("")
const [filter, setFilter] = useState("")
const [showAddPanel, setShowAddPanel] = useState(false)
const hasLoaded = useRef(false) const hasLoaded = useRef(false)
@@ -56,20 +54,341 @@ export function TemplateManager() {
} }
} }
const resetForm = () => { const filteredTemplates = templates.filter((t) => t.name.toLowerCase().includes(filter.toLowerCase()))
setNewName("")
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")
}
return (
<div className="space-y-8">
{/* Header */}
<header className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-8">
<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-2">Template Library</h2>
<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 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-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>
<button
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"
>
<span className="material-symbols-outlined text-sm">add</span>
Create Template
</button>
</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>
{!isElectron && (
<div className="text-caption text-warning bg-warning/10 px-4 py-2 border border-warning/20 font-medium rounded-md">
Running in web mode. Templates will not persist across reloads.
</div>
)}
{message && (
<div className="text-caption text-body bg-canvas border-2 border-hairline px-4 py-2 rounded-md font-medium">
{message}
</div>
)}
{/* Template Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{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?.()}
/>
))}
<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>
{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">
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
}) {
const status = "READY"
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="relative aspect-[3/4] bg-canvas mb-4 border-2 border-hairline overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center">
<TemplatePreview template={template} />
</div>
<div className="absolute inset-0 bg-primary/5 opacity-0 group-hover:opacity-100 transition-opacity" />
</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-2 border-surface-cream-strong bg-canvas text-body-md focus:border-primary focus:ring-0"
/>
<div className="flex gap-2">
<button
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"
>
Save
</button>
<button
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"
>
Cancel
</button>
</div>
</div>
) : (
<>
<div className="flex items-start justify-between mb-2">
<h3 className="text-title-md font-medium text-ink">{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>
</div>
<p className="text-caption text-muted mb-4">
{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-4 border-t-2 border-surface-cream-strong flex justify-between items-center">
<div className="flex gap-2">
<button
onClick={onStartEdit}
className="p-1.5 text-muted hover:text-primary hover:bg-primary/5 rounded transition-all"
title="Edit name"
>
<span className="material-symbols-outlined text-[18px]">edit</span>
</button>
<button
onClick={onDelete}
className="p-1.5 text-muted hover:text-error hover:bg-error/5 rounded transition-all"
title="Delete"
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={onUse}
className="text-caption text-primary hover:underline font-medium"
>
Use in job
</button>
<span className="text-[10px] font-mono text-success uppercase">{status}</span>
</div>
</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-4xl mb-2">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) setTemplateFile(null)
setMetadataFile(null) setMetadataFile(null)
setBubbleTemplateFile(null) setBubbleTemplateFile(null)
setFilledTemplateFile(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 handleAddTemplate = async () => { const handleMetadataFile = (file: File) => {
if (!templateDir) { setMessage("Select a template directory first"); return } setMetadataFile(file)
if (!templateFile || !metadataFile) { setMessage("Select both template image and metadata.json"); return } const reader = new FileReader()
if (!newName.trim()) { setMessage("Enter a template name"); return } 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 { try {
let templatePath = "" let templatePath = ""
let metadataPath = "" let metadataPath = ""
@@ -116,7 +435,7 @@ export function TemplateManager() {
await window.electronAPI.writeBinaryFile(tmpFilled, ftBase64.split(",")[1]) 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 templatePath = copied.templatePath
metadataPath = copied.metadataPath metadataPath = copied.metadataPath
bubblePath = copied.bubbleTemplatePath bubblePath = copied.bubbleTemplatePath
@@ -130,7 +449,7 @@ export function TemplateManager() {
const entry: TemplateEntry = { const entry: TemplateEntry = {
id: generateId(), id: generateId(),
name: newName.trim(), name: name.trim(),
templatePath, templatePath,
metadataPath, metadataPath,
bubbleTemplatePath: bubblePath, bubbleTemplatePath: bubblePath,
@@ -141,205 +460,137 @@ export function TemplateManager() {
const next = [...templates, entry] const next = [...templates, entry]
await saveTemplatesToDir(templateDir, next) await saveTemplatesToDir(templateDir, next)
addTemplate(entry) addTemplate(entry)
resetForm() onClose()
setMessage(`Template "${entry.name}" saved`)
} catch (err) { } catch (err) {
setMessage(`Error: ${err instanceof Error ? err.message : String(err)}`) setError(`Error: ${err instanceof Error ? err.message : String(err)}`)
} finally { } finally {
setLoading(false) setSaving(false)
} }
} }
const handleDelete = async (id: string) => { if (!open) return null
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()
}
return ( return (
<div className="flex flex-col gap-4 p-4"> <div className="fixed inset-0 z-50">
<div className="flex items-center gap-3"> <div
<span className="text-title-sm font-medium text-ink">Template Directory</span> className="absolute inset-0 bg-surface-dark/40 backdrop-blur-sm"
{isElectron && ( onClick={onClose}
/>
<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>
<h2 className="text-display-sm font-serif text-ink">New Template</h2>
<p className="text-caption text-muted mt-1">Define extraction parameters</p>
</div>
<button <button
onClick={pickDirectory} onClick={onClose}
className="px-3 py-1.5 rounded-md bg-surface-card border border-hairline text-nav hover:bg-surface-cream-strong transition-all" className="material-symbols-outlined p-2 hover:bg-surface-soft rounded transition-colors"
> >
{templateDir ? "Change Directory" : "Choose Directory"} close
</button> </button>
)}
</div> </div>
{templateDir && ( <div className="flex-1 overflow-y-auto p-8 space-y-8 custom-scrollbar">
<div className="text-caption text-muted bg-surface-soft px-3 py-2 rounded-md border border-hairline"> <div className="space-y-2">
{templateDir} <label className="block text-caption-uppercase text-muted uppercase tracking-wider">Template Name</label>
</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 <input
type="text" type="text"
placeholder="Template name" value={name}
value={newName} onChange={(e) => setName(e.target.value)}
onChange={(e) => setNewName(e.target.value)} placeholder="e.g. Q4 Performance Survey"
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 border-2 border-surface-cream-strong p-3 text-body-md focus:border-primary focus:ring-0 bg-surface-card"
/> />
<div className="grid grid-cols-2 gap-2"> </div>
<div className="space-y-2">
<label className="block text-caption-uppercase text-muted uppercase tracking-wider">Reference Blank Image</label>
<button <button
onClick={() => selectFile("image/png,image/jpeg", setTemplateFile)} 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" 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"}`}
> >
{templateFile ? `Template: ${templateFile.name}` : "Select Template Image"} <span className="material-symbols-outlined text-4xl text-muted mb-4 block">upload_file</span>
<p className="text-body-md text-ink">
{templateFile ? templateFile.name : "Drop blank scan or browse"}
</p>
{!templateFile && <p className="text-[10px] font-mono text-muted mt-2">MAX 10MB PNG, JPG</p>}
</button> </button>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="block text-caption-uppercase text-muted uppercase tracking-wider">JSON Configuration</label>
<button <button
onClick={() => selectFile("application/json", setMetadataFile)} onClick={() => selectFile("application/json", handleMetadataFile)}
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left" className="text-[10px] font-mono text-primary hover:underline uppercase"
> >
{metadataFile ? `Metadata: ${metadataFile.name}` : "Select metadata.json"} Upload .JSON
</button> </button>
</div>
<div className="relative group">
<textarea
value={metadataText}
onChange={(e) => setMetadataText(e.target.value)}
placeholder='{
"template_id": "auto_gen",
"fields": [
{ "id": "q1", "type": "bubble", "coords": [120, 450] }
]
}'
rows={10}
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"
/>
<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 className="grid grid-cols-2 gap-4">
<button <button
onClick={() => selectFile("image/png,image/jpeg", setBubbleTemplateFile)} 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" className={`border-2 p-4 text-left transition-all ${
bubbleTemplateFile
? "border-primary bg-primary/5"
: "border-surface-cream-strong bg-surface-card hover:bg-surface-soft"
}`}
> >
{bubbleTemplateFile ? `Bubble: ${bubbleTemplateFile.name}` : "Select Bubble Template (opt)"} <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>
<button <button
onClick={() => selectFile("image/png,image/jpeg", setFilledTemplateFile)} 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" 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"
}`}
> >
{filledTemplateFile ? `Filled: ${filledTemplateFile.name}` : "Select Filled Bubble (opt)"} <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> </button>
</div> </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 && ( {error && (
<div className="text-caption px-3 py-2 rounded-md border bg-surface-soft border-hairline text-body"> <div className="text-caption text-error bg-error/10 px-4 py-2 border border-error/20 rounded-md">{error}</div>
{message}
</div>
)} )}
</div>
<div className="flex flex-col gap-2"> <div className="p-8 border-t-2 border-surface-cream-strong bg-canvas flex gap-4">
<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>
)}
{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 <button
onClick={() => saveEdit(t.id)} onClick={handleSave}
className="px-3 py-1.5 rounded-md bg-primary text-on-primary text-nav font-medium hover:bg-primary-active transition-all" 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"
> >
Save {saving ? "Saving..." : "Save Template"}
</button> </button>
<button <button
onClick={cancelEdit} onClick={onClose}
className="px-3 py-1.5 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all" 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"
> >
Cancel Cancel
</button> </button>
</div> </div>
</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>
)}
</div>
))}
</div>
</div> </div>
) )
} }
+4 -4
View File
@@ -19,11 +19,11 @@ export function TabBar({ active, onChange }: TabBarProps) {
<button <button
key={t.key} key={t.key}
onClick={() => onChange(t.key)} onClick={() => onChange(t.key)}
className={ className={`px-4 py-1.5 rounded-md text-nav font-medium transition-all ${
active === t.key 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" ? "bg-surface-cream-strong text-ink border border-hairline"
: "px-4 py-1.5 rounded-md text-muted hover:text-ink hover:bg-surface-card text-nav font-medium transition-all" : "text-muted hover:text-ink hover:bg-surface-card"
} }`}
> >
{t.label} {t.label}
</button> </button>
+6
View File
@@ -119,6 +119,7 @@
--animate-fade-in: fade-in 0.2s ease-out; --animate-fade-in: fade-in 0.2s ease-out;
--animate-slide-up: slide-up 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); --color-canvas-dot: rgba(108,106,100,0.08);
} }
@@ -133,6 +134,11 @@
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.material-symbols-outlined { .material-symbols-outlined {
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24; font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
display: inline-block; display: inline-block;