diff --git a/electron/main.ts b/electron/main.ts
index 097b758..155094b 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -1,7 +1,7 @@
import { app, BrowserWindow, ipcMain, dialog, Menu, nativeTheme } from "electron"
import { join, dirname } from "node:path"
import { fileURLToPath } from "node:url"
-import { readFileSync, writeFileSync } from "node:fs"
+import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, readdirSync } from "node:fs"
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -69,6 +69,14 @@ ipcMain.handle("dialog:openFile", async (_event, filters: { name: string; extens
return result.filePaths
})
+ipcMain.handle("dialog:openDirectory", async () => {
+ const result = await dialog.showOpenDialog(win!, {
+ properties: ["openDirectory"],
+ })
+ win?.webContents?.focus()
+ return result.filePaths?.[0] ?? ""
+})
+
ipcMain.handle("dialog:saveFile", async (_event, defaultName: string, filters: { name: string; extensions: string[] }[]) => {
const result = await dialog.showSaveDialog(win!, {
defaultPath: defaultName,
@@ -97,6 +105,25 @@ ipcMain.handle("file:writeBinary", async (_event, path: string, base64: string)
writeFileSync(path, Buffer.from(base64, "base64"))
})
+ipcMain.handle("dir:read", async (_event, dirPath: string) => {
+ if (!existsSync(dirPath)) return []
+ const entries = readdirSync(dirPath, { withFileTypes: true })
+ return entries.map((e) => ({ name: e.name, isDirectory: e.isDirectory() }))
+})
+
+ipcMain.handle("dir:ensure", async (_event, dirPath: string) => {
+ if (!existsSync(dirPath)) mkdirSync(dirPath, { recursive: true })
+ return dirPath
+})
+
+ipcMain.handle("path:exists", async (_event, path: string) => {
+ return existsSync(path)
+})
+
+ipcMain.handle("file:copy", async (_event, src: string, dest: string) => {
+ copyFileSync(src, dest)
+})
+
app.whenReady().then(createWindow)
app.on("window-all-closed", () => {
diff --git a/electron/preload.ts b/electron/preload.ts
index a46728e..f967929 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -12,6 +12,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
},
openFileDialog: (filters: { name: string; extensions: string[] }[]) =>
ipcRenderer.invoke("dialog:openFile", filters),
+ openDirectoryDialog: () => ipcRenderer.invoke("dialog:openDirectory"),
saveFileDialog: (defaultName: string, filters: { name: string; extensions: string[] }[]) =>
ipcRenderer.invoke("dialog:saveFile", defaultName, filters),
readFile: (path: string) => ipcRenderer.invoke("file:read", path),
@@ -19,4 +20,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
readImage: (path: string) => ipcRenderer.invoke("file:readImage", path),
writeBinaryFile: (path: string, base64: string) => ipcRenderer.invoke("file:writeBinary", path, base64),
getAppPath: () => ipcRenderer.invoke("app:getPath"),
+ readDir: (path: string) => ipcRenderer.invoke("dir:read", path),
+ ensureDir: (path: string) => ipcRenderer.invoke("dir:ensure", path),
+ pathExists: (path: string) => ipcRenderer.invoke("path:exists", path),
+ copyFile: (src: string, dest: string) => ipcRenderer.invoke("file:copy", src, dest),
})
diff --git a/src/App.tsx b/src/App.tsx
index dee7182..518557c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useRef } from "react"
+import { useEffect, useState, useRef, Component, type ReactNode } from "react"
import { Toolbar } from "./components/Toolbar"
import { TitleBar } from "./components/TitleBar"
import { LeftSidebar } from "./components/LeftSidebar"
@@ -7,15 +7,40 @@ import { PropertiesPanel } from "./components/PropertiesPanel"
import { StatusBar } from "./components/StatusBar"
import { KeyboardCheatSheet } from "./components/KeyboardCheatSheet"
import { ResizeHandle } from "./components/ResizeHandle"
+import { TabBar } from "./components/TabBar"
+import { ApiClientPanel } from "./components/ApiClient/ApiClientPanel"
import { useStore } from "./store/useStore"
import { loadTemplateDialog } from "./utils/loadFile"
+import type { AppTab } from "./components/TabBar"
const MIN_SIDEBAR = 160
const MAX_SIDEBAR = 500
+class TabErrorBoundary extends Component<{ children: ReactNode; tab: string }, { hasError: boolean; error: string }> {
+ constructor(props: { children: ReactNode; tab: string }) {
+ super(props)
+ this.state = { hasError: false, error: "" }
+ }
+ static getDerivedStateFromError(error: Error) {
+ return { hasError: true, error: error.message || String(error) }
+ }
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
{this.props.tab} Tab Crashed
+
{this.state.error}
+
+ )
+ }
+ return this.props.children
+ }
+}
+
export default function App() {
const containerRef = useRef(null)
const [showCheatSheet, setShowCheatSheet] = useState(false)
+ const [activeTab, setActiveTab] = useState("editor")
const [leftWidth, setLeftWidth] = useState(280)
const [rightWidth, setRightWidth] = useState(320)
const [leftCollapsed, setLeftCollapsed] = useState(false)
@@ -142,18 +167,29 @@ export default function App() {
return (
-
-
-
setLeftCollapsed((v) => !v)} />
- {!leftCollapsed && setLeftWidth(Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, clientX)))} />}
-
- {!rightCollapsed && {
- if (!containerRef.current) return
- const cr = containerRef.current.getBoundingClientRect()
- setRightWidth(Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, cr.right - clientX)))
- }} />}
- setRightCollapsed((v) => !v)} />
-
+
+ {activeTab === "editor" ? (
+
+
+
+
setLeftCollapsed((v) => !v)} />
+ {!leftCollapsed && setLeftWidth(Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, clientX)))} />}
+
+ {!rightCollapsed && {
+ if (!containerRef.current) return
+ const cr = containerRef.current.getBoundingClientRect()
+ setRightWidth(Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, cr.right - clientX)))
+ }} />}
+ setRightCollapsed((v) => !v)} />
+
+
+ ) : (
+
+
+
+ )}
{showCheatSheet &&
setShowCheatSheet(false)} />}
diff --git a/src/components/ApiClient/ApiClientPanel.tsx b/src/components/ApiClient/ApiClientPanel.tsx
new file mode 100644
index 0000000..de8a686
--- /dev/null
+++ b/src/components/ApiClient/ApiClientPanel.tsx
@@ -0,0 +1,57 @@
+import { Component, type ReactNode } from "react"
+import { ServerSettings } from "./ServerSettings"
+import { TemplateManager } from "./TemplateManager"
+import { JobRunner } from "./JobRunner"
+
+interface EBProps {
+ children: ReactNode
+ name: string
+}
+
+interface EBState {
+ hasError: boolean
+ error: string
+}
+
+class SafeWrap extends Component {
+ constructor(props: EBProps) {
+ super(props)
+ this.state = { hasError: false, error: "" }
+ }
+
+ static getDerivedStateFromError(error: Error): EBState {
+ return { hasError: true, error: error?.message || String(error) }
+ }
+
+ componentDidCatch(error: Error) {
+ console.error(`[SafeWrap ${this.props.name}]`, error)
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
{this.props.name} crashed
+
{this.state.error}
+
+ )
+ }
+ return this.props.children
+ }
+}
+
+export function ApiClientPanel() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/ApiClient/JobRunner.tsx b/src/components/ApiClient/JobRunner.tsx
new file mode 100644
index 0000000..b5cf3e7
--- /dev/null
+++ b/src/components/ApiClient/JobRunner.tsx
@@ -0,0 +1,463 @@
+import { useState, useRef } from "react"
+import { useApiStore } from "../../store/useApiStore"
+import { submitJob, pollUntilComplete, getOutputUrl, getJobStatus } from "../../utils/apiClient"
+import type { JobRun, JobStatusResponse } from "../../types/api"
+
+function generateId() {
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
+}
+
+async function fileFromPath(path: string): Promise {
+ if (!window.electronAPI) throw new Error("Electron API required for file paths")
+ const content = await window.electronAPI.readFile(path)
+ const name = path.split(/[/\\]/).pop() || "file"
+ const ext = name.split(".").pop()?.toLowerCase() || ""
+ const mime = ext === "json" ? "application/json" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png"
+ return new File([new Blob([content], { type: mime })], name, { type: mime })
+}
+
+async function imageFileFromPath(path: string): Promise {
+ if (!window.electronAPI) throw new Error("Electron API required for image paths")
+ const dataUrl = await window.electronAPI.readImage(path)
+ const name = path.split(/[/\\]/).pop() || "image.jpg"
+ const ext = name.split(".").pop()?.toLowerCase() || "jpg"
+ const mime = ext === "png" ? "image/png" : "image/jpeg"
+ const base64 = dataUrl.split(",")[1]
+ const binary = atob(base64)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
+ return new File([bytes], name, { type: mime })
+}
+
+export function JobRunner() {
+ const serverUrl = useApiStore((s) => s.serverUrl)
+ const templates = useApiStore((s) => s.templates)
+ const runs = useApiStore((s) => s.runs)
+ const addRun = useApiStore((s) => s.addRun)
+ const updateRun = useApiStore((s) => s.updateRun)
+ const activeRunId = useApiStore((s) => s.activeRunId)
+ const setActiveRunId = useApiStore((s) => s.setActiveRunId)
+
+ const [selectedTemplateId, setSelectedTemplateId] = useState("")
+ const [scannedPaths, setScannedPaths] = useState([])
+ const [mode, setMode] = useState<"sync" | "async">("async")
+ const [statusMsg, setStatusMsg] = useState("")
+ const [submitting, setSubmitting] = useState(false)
+ const pollRefs = useRef>({})
+
+ const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) || null
+
+ const pickFiles = async (opts: { multiple?: boolean; accept?: string } = {}) => {
+ if (!window.electronAPI) {
+ return new Promise((resolve) => {
+ const input = document.createElement("input")
+ input.type = "file"
+ input.accept = opts.accept || "image/png,image/jpeg"
+ if (opts.multiple) input.multiple = true
+ input.onchange = () => {
+ const files = Array.from(input.files || [])
+ resolve(files.map((f) => f.name))
+ }
+ input.click()
+ })
+ }
+ const exts = opts.accept?.includes("json") ? ["json"] : ["jpg", "jpeg", "png"]
+ const paths = await window.electronAPI.openFileDialog([{ name: "Files", extensions: exts }])
+ return paths || []
+ }
+
+ const pickScannedFiles = async () => {
+ const paths = await pickFiles({ multiple: true })
+ if (paths.length > 0) setScannedPaths(paths)
+ }
+
+ const handleSubmit = async () => {
+ if (!selectedTemplate) { setStatusMsg("Select a template first"); return }
+ if (scannedPaths.length === 0) { setStatusMsg("Select at least one scanned sheet"); return }
+
+ setSubmitting(true)
+ setStatusMsg("Preparing files...")
+
+ try {
+ let templateFile: File
+ let metadataFile: File
+ let bubbleTemplateFile: File | undefined
+ let filledTemplateFile: File | undefined
+
+ if (window.electronAPI) {
+ templateFile = await imageFileFromPath(selectedTemplate.templatePath)
+ metadataFile = await fileFromPath(selectedTemplate.metadataPath)
+ if (selectedTemplate.bubbleTemplatePath) bubbleTemplateFile = await imageFileFromPath(selectedTemplate.bubbleTemplatePath)
+ if (selectedTemplate.filledTemplatePath) filledTemplateFile = await imageFileFromPath(selectedTemplate.filledTemplatePath)
+ } else {
+ const tplBlob = await fetch(selectedTemplate.templatePath).then((r) => r.blob())
+ const metaBlob = await fetch(selectedTemplate.metadataPath).then((r) => r.blob())
+ templateFile = new File([tplBlob], "template.jpg", { type: "image/jpeg" })
+ metadataFile = new File([metaBlob], "metadata.json", { type: "application/json" })
+ if (selectedTemplate.bubbleTemplatePath) {
+ const btBlob = await fetch(selectedTemplate.bubbleTemplatePath).then((r) => r.blob())
+ bubbleTemplateFile = new File([btBlob], "bubble_template.jpg", { type: "image/jpeg" })
+ }
+ if (selectedTemplate.filledTemplatePath) {
+ const ftBlob = await fetch(selectedTemplate.filledTemplatePath).then((r) => r.blob())
+ filledTemplateFile = new File([ftBlob], "filled_template.jpg", { type: "image/jpeg" })
+ }
+ }
+
+ const runId = generateId()
+ const run: JobRun = {
+ id: runId,
+ templateId: selectedTemplate.id,
+ templateName: selectedTemplate.name,
+ scannedPaths: [...scannedPaths],
+ serverUrl,
+ mode,
+ status: "running",
+ jobs: [],
+ createdAt: Date.now(),
+ }
+ addRun(run)
+
+ for (const scanPath of scannedPaths) {
+ let scannedFile: File
+ if (window.electronAPI) {
+ scannedFile = await imageFileFromPath(scanPath)
+ } else {
+ const input = document.createElement("input")
+ input.type = "file"
+ input.accept = "image/png,image/jpeg"
+ scannedFile = await new Promise((resolve) => {
+ input.onchange = () => resolve(input.files?.[0] as File)
+ input.click()
+ })
+ }
+
+ setStatusMsg(`Submitting ${scannedFile.name}...`)
+ const response = await submitJob(serverUrl, {
+ template: templateFile,
+ metadata: metadataFile,
+ scanned: scannedFile,
+ bubbleTemplate: bubbleTemplateFile || null,
+ filledTemplate: filledTemplateFile || null,
+ }, mode === "sync")
+
+ const jobStatus: JobStatusResponse = {
+ job_id: response.job_id,
+ status: response.status === "queued" ? "queued" : "processing",
+ }
+
+ updateRun(runId, (currentRun) => ({
+ jobs: [...currentRun.jobs, jobStatus],
+ }))
+
+ if (mode === "async") {
+ pollRefs.current[response.job_id] = true
+ pollUntilComplete(serverUrl, response.job_id, (status) => {
+ if (!pollRefs.current[response.job_id]) return
+ updateRun(runId, (currentRun) => ({
+ jobs: currentRun.jobs.map((j) =>
+ j.job_id === status.job_id ? status : j
+ ),
+ }))
+ }).then((final) => {
+ if (!pollRefs.current[response.job_id]) return
+ updateRun(runId, (currentRun) => ({
+ jobs: currentRun.jobs.map((j) =>
+ j.job_id === final.job_id ? final : j
+ ),
+ status: final.status === "failed" ? "failed" : "completed",
+ }))
+ }).catch((err) => {
+ setStatusMsg(`Polling error: ${err.message}`)
+ })
+ } else {
+ const finalStatus = await getJobStatus(serverUrl, response.job_id)
+ updateRun(runId, (currentRun) => ({
+ jobs: currentRun.jobs.map((j) =>
+ j.job_id === finalStatus.job_id ? finalStatus : j
+ ),
+ status: finalStatus.status === "failed" ? "failed" : "completed",
+ }))
+ }
+ }
+
+ setStatusMsg(`Submitted ${scannedPaths.length} job(s) in ${mode} mode`)
+ } catch (err) {
+ setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`)
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const activeRun = runs.find((r) => r.id === activeRunId) || runs[runs.length - 1] || null
+
+ return (
+
+
+
Run Grading
+
+
+
+
+
+
+
+
+
+ {scannedPaths.length > 0 && (
+
+ {scannedPaths.map((p, i) => (
+ - {p.split(/[/\\]/).pop() || p}
+ ))}
+
+ )}
+
+
+
+
+
+ {(["async", "sync"] as const).map((m) => (
+
+ ))}
+
+
+
+
+
+ {statusMsg && (
+
+ {statusMsg}
+
+ )}
+
+
+
+
Runs
+ {runs.length === 0 && (
+
+ No grading runs yet.
+
+ )}
+ {runs.map((run) => (
+
+ ))}
+
+
+ {activeRun &&
}
+
+ )
+}
+
+function ResultsPanel({ run }: { run: JobRun }) {
+ const serverUrl = useApiStore((s) => s.serverUrl)
+ const [expandedJob, setExpandedJob] = useState(null)
+
+ const allCompleted = run.jobs.length > 0 && run.jobs.every((j) => j.status === "completed")
+ const anyFailed = run.jobs.some((j) => j.status === "failed")
+ const overallStatus = anyFailed ? "failed" : allCompleted ? "completed" : "running"
+
+ return (
+
+
+ Results
+
+ {overallStatus.toUpperCase()}
+
+
+
+ {run.jobs.length === 0 && (
+
+ No jobs yet.
+
+ )}
+
+ {run.jobs.map((job) => (
+
setExpandedJob(expandedJob === job.job_id ? null : job.job_id)}
+ />
+ ))}
+
+ )
+}
+
+function JobResultCard({
+ job,
+ serverUrl,
+ expanded,
+ onToggle,
+}: {
+ job: JobStatusResponse
+ serverUrl: string
+ expanded: boolean
+ onToggle: () => void
+}) {
+ const statusColor =
+ job.status === "completed"
+ ? "text-success border-success/20 bg-success/5"
+ : job.status === "failed"
+ ? "text-error border-error/20 bg-error/5"
+ : "text-warning border-warning/20 bg-warning/5"
+
+ const resultData = job.result as Record | undefined
+ const extracted = resultData?.ExtractedData as Record | undefined
+
+ return (
+
+
+
+ {expanded && (
+
+ {job.error_message && (
+
+ {job.error_message}
+
+ )}
+
+ {extracted && Object.keys(extracted).length > 0 && (
+
+
Extracted Answers
+
+ {Object.entries(extracted).map(([qNum, rawValue]) => {
+ const label = isNaN(Number(qNum)) ? qNum : `Q${qNum}`
+ const answers = formatExtractedValue(rawValue)
+ return (
+
+ {label}
+ {answers}
+
+ )
+ })}
+
+
+ )}
+
+ {job.output_files && job.output_files.length > 0 && (
+
+ )}
+
+ {job.result && !extracted && (
+
+
Raw Result
+
+ {JSON.stringify(job.result, null, 2)}
+
+
+ )}
+
+ )}
+
+ )
+}
+
+function formatExtractedValue(value: unknown): string {
+ if (value === null || value === undefined) return "—"
+ if (Array.isArray(value)) {
+ return value.map((v) => formatExtractedValue(v)).join(", ") || "—"
+ }
+ if (typeof value === "object") {
+ // Some APIs return objects for each answer; try to extract meaningful string
+ const obj = value as Record
+ if ("answer" in obj && typeof obj.answer === "string") return obj.answer
+ if ("value" in obj && typeof obj.value === "string") return obj.value
+ if ("selected" in obj && typeof obj.selected === "string") return obj.selected
+ return JSON.stringify(value)
+ }
+ return String(value)
+}
diff --git a/src/components/ApiClient/ServerSettings.tsx b/src/components/ApiClient/ServerSettings.tsx
new file mode 100644
index 0000000..c227932
--- /dev/null
+++ b/src/components/ApiClient/ServerSettings.tsx
@@ -0,0 +1,59 @@
+import { useState } from "react"
+import { useApiStore } from "../../store/useApiStore"
+import { checkHealth } from "../../utils/apiClient"
+
+export function ServerSettings() {
+ const serverUrl = useApiStore((s) => s.serverUrl)
+ const setServerUrl = useApiStore((s) => s.setServerUrl)
+ const [inputUrl, setInputUrl] = useState(serverUrl)
+ const [checking, setChecking] = useState(false)
+ const [health, setHealth] = useState<{ ok: boolean; msg: string } | null>(null)
+
+ const handleCheck = async () => {
+ setChecking(true)
+ setHealth(null)
+ try {
+ const data = await checkHealth(inputUrl)
+ setHealth({ ok: true, msg: `Server OK — ${data.status} (Redis: ${data.redis}, Storage: ${data.storage})` })
+ setServerUrl(inputUrl)
+ } catch (err) {
+ setHealth({ ok: false, msg: `Unreachable: ${err instanceof Error ? err.message : String(err)}` })
+ } finally {
+ setChecking(false)
+ }
+ }
+
+ return (
+
+
Server Settings
+
+ setInputUrl(e.target.value)}
+ placeholder="http://localhost:8000"
+ className="flex-1 px-3 py-2 rounded-md border border-hairline bg-canvas text-body-md focus:outline-none focus:ring-2 focus:ring-primary/30"
+ />
+
+
+ {health && (
+
+ {health.msg}
+
+ )}
+
+ )
+}
diff --git a/src/components/ApiClient/TemplateManager.tsx b/src/components/ApiClient/TemplateManager.tsx
new file mode 100644
index 0000000..78488bb
--- /dev/null
+++ b/src/components/ApiClient/TemplateManager.tsx
@@ -0,0 +1,345 @@
+import { useState, useEffect, useRef } from "react"
+import { useApiStore } from "../../store/useApiStore"
+import { loadTemplatesFromDir, saveTemplatesToDir, copyTemplateFiles } from "../../utils/templateStorage"
+import type { TemplateEntry } from "../../types/api"
+
+function generateId() {
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
+}
+
+export function TemplateManager() {
+ const templateDir = useApiStore((s) => s.templateDir)
+ const setTemplateDir = useApiStore((s) => s.setTemplateDir)
+ const templates = useApiStore((s) => s.templates)
+ const loadTemplates = useApiStore((s) => s.loadTemplates)
+ const addTemplate = useApiStore((s) => s.addTemplate)
+ const removeTemplate = useApiStore((s) => s.removeTemplate)
+ const updateRun = useApiStore((s) => s.updateRun)
+
+ const [isElectron] = useState(() => !!window.electronAPI)
+ const [loading, setLoading] = useState(false)
+ const [message, setMessage] = useState("")
+
+ // Add form state
+ const [newName, setNewName] = useState("")
+ const [templateFile, setTemplateFile] = useState(null)
+ const [metadataFile, setMetadataFile] = useState(null)
+ const [bubbleTemplateFile, setBubbleTemplateFile] = useState(null)
+ const [filledTemplateFile, setFilledTemplateFile] = useState(null)
+
+ // Edit state
+ const [editingId, setEditingId] = useState(null)
+ const [editName, setEditName] = useState("")
+
+ const hasLoaded = useRef(false)
+
+ useEffect(() => {
+ if (hasLoaded.current) return
+ hasLoaded.current = true
+ if (!templateDir) return
+ setLoading(true)
+ loadTemplatesFromDir(templateDir)
+ .then((list) => loadTemplates(list))
+ .catch(() => {})
+ .finally(() => setLoading(false))
+ }, [templateDir, loadTemplates])
+
+ const pickDirectory = async () => {
+ if (!window.electronAPI) return
+ const dir = await window.electronAPI.openDirectoryDialog()
+ if (dir) {
+ setTemplateDir(dir)
+ setLoading(true)
+ const list = await loadTemplatesFromDir(dir)
+ loadTemplates(list)
+ setLoading(false)
+ }
+ }
+
+ const resetForm = () => {
+ setNewName("")
+ setTemplateFile(null)
+ setMetadataFile(null)
+ setBubbleTemplateFile(null)
+ setFilledTemplateFile(null)
+ }
+
+ const handleAddTemplate = async () => {
+ if (!templateDir) { setMessage("Select a template directory first"); return }
+ if (!templateFile || !metadataFile) { setMessage("Select both template image and metadata.json"); return }
+ if (!newName.trim()) { setMessage("Enter a template name"); return }
+
+ setLoading(true)
+ try {
+ let templatePath = ""
+ let metadataPath = ""
+ let bubblePath: string | undefined
+ let filledPath: string | undefined
+
+ if (window.electronAPI) {
+ const tmpDir = await window.electronAPI.ensureDir(`${templateDir}/.tmp`)
+ const tmpTemplate = `${tmpDir}/${templateFile.name}`
+ const tmpMetadata = `${tmpDir}/${metadataFile.name}`
+ const tmpBubble = bubbleTemplateFile ? `${tmpDir}/${bubbleTemplateFile.name}` : undefined
+ const tmpFilled = filledTemplateFile ? `${tmpDir}/${filledTemplateFile.name}` : undefined
+
+ const tplReader = new FileReader()
+ const metaReader = new FileReader()
+
+ const tplBase64 = await new Promise((resolve) => {
+ tplReader.onload = () => resolve(tplReader.result as string)
+ tplReader.readAsDataURL(templateFile)
+ })
+ const metaText = await new Promise((resolve) => {
+ metaReader.onload = () => resolve(metaReader.result as string)
+ metaReader.readAsText(metadataFile)
+ })
+
+ await window.electronAPI.writeBinaryFile(tmpTemplate, tplBase64.split(",")[1])
+ await window.electronAPI.writeFile(tmpMetadata, metaText)
+
+ if (tmpBubble && bubbleTemplateFile) {
+ const btReader = new FileReader()
+ const btBase64 = await new Promise((resolve) => {
+ btReader.onload = () => resolve(btReader.result as string)
+ btReader.readAsDataURL(bubbleTemplateFile)
+ })
+ await window.electronAPI.writeBinaryFile(tmpBubble, btBase64.split(",")[1])
+ }
+
+ if (tmpFilled && filledTemplateFile) {
+ const ftReader = new FileReader()
+ const ftBase64 = await new Promise((resolve) => {
+ ftReader.onload = () => resolve(ftReader.result as string)
+ ftReader.readAsDataURL(filledTemplateFile)
+ })
+ await window.electronAPI.writeBinaryFile(tmpFilled, ftBase64.split(",")[1])
+ }
+
+ const copied = await copyTemplateFiles(tmpTemplate, tmpMetadata, templateDir, newName.trim(), tmpBubble, tmpFilled)
+ templatePath = copied.templatePath
+ metadataPath = copied.metadataPath
+ bubblePath = copied.bubbleTemplatePath
+ filledPath = copied.filledTemplatePath
+ } else {
+ templatePath = URL.createObjectURL(templateFile)
+ metadataPath = URL.createObjectURL(metadataFile)
+ if (bubbleTemplateFile) bubblePath = URL.createObjectURL(bubbleTemplateFile)
+ if (filledTemplateFile) filledPath = URL.createObjectURL(filledTemplateFile)
+ }
+
+ const entry: TemplateEntry = {
+ id: generateId(),
+ name: newName.trim(),
+ templatePath,
+ metadataPath,
+ bubbleTemplatePath: bubblePath,
+ filledTemplatePath: filledPath,
+ createdAt: Date.now(),
+ }
+
+ const next = [...templates, entry]
+ await saveTemplatesToDir(templateDir, next)
+ addTemplate(entry)
+ resetForm()
+ setMessage(`Template "${entry.name}" saved`)
+ } catch (err) {
+ setMessage(`Error: ${err instanceof Error ? err.message : String(err)}`)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleDelete = async (id: string) => {
+ if (!templateDir) return
+ const next = templates.filter((t) => t.id !== id)
+ await saveTemplatesToDir(templateDir, next)
+ removeTemplate(id)
+ setMessage("Template removed")
+ }
+
+ const startEdit = (t: TemplateEntry) => {
+ setEditingId(t.id)
+ setEditName(t.name)
+ }
+
+ const cancelEdit = () => {
+ setEditingId(null)
+ setEditName("")
+ }
+
+ const saveEdit = async (id: string) => {
+ if (!editName.trim()) { setMessage("Name cannot be empty"); return }
+ if (!templateDir) return
+ const next = templates.map((t) => t.id === id ? { ...t, name: editName.trim() } : t)
+ await saveTemplatesToDir(templateDir, next)
+ loadTemplates(next)
+ setEditingId(null)
+ setEditName("")
+ setMessage("Template updated")
+ }
+
+ const selectFile = (accept: string, callback: (file: File) => void) => {
+ const input = document.createElement("input")
+ input.type = "file"
+ input.accept = accept
+ input.onchange = () => {
+ const file = input.files?.[0]
+ if (file) callback(file)
+ }
+ input.click()
+ }
+
+ return (
+
+
+ Template Directory
+ {isElectron && (
+
+ )}
+
+
+ {templateDir && (
+
+ {templateDir}
+
+ )}
+
+ {!isElectron && (
+
+ Running in web mode. Templates will not persist across reloads.
+
+ )}
+
+
+
Add New Template
+
setNewName(e.target.value)}
+ className="px-3 py-2 rounded-md border border-hairline bg-canvas text-body-md focus:outline-none focus:ring-2 focus:ring-primary/30"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {message && (
+
+ {message}
+
+ )}
+
+
+
Stored Templates
+ {templates.length === 0 && (
+
+ No templates yet. Add one above.
+
+ )}
+ {templates.map((t) => (
+
+ {editingId === t.id ? (
+
+
setEditName(e.target.value)}
+ className="px-3 py-2 rounded-md border border-hairline bg-canvas text-body-md focus:outline-none focus:ring-2 focus:ring-primary/30"
+ />
+
+
+
+
+
+ ) : (
+
+
+ {t.name}
+
+ {new Date(t.createdAt).toLocaleString()}
+ {t.bubbleTemplatePath && " · bubble"}
+ {t.filledTemplatePath && " · filled"}
+
+
+
+
+
+
+
+ )}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx
new file mode 100644
index 0000000..84134e1
--- /dev/null
+++ b/src/components/TabBar.tsx
@@ -0,0 +1,33 @@
+import { useState } from "react"
+
+export type AppTab = "editor" | "api"
+
+interface TabBarProps {
+ active: AppTab
+ onChange: (tab: AppTab) => void
+}
+
+export function TabBar({ active, onChange }: TabBarProps) {
+ const tabs: { key: AppTab; label: string }[] = [
+ { key: "editor", label: "Editor" },
+ { key: "api", label: "API Client" },
+ ]
+
+ return (
+
+ {tabs.map((t) => (
+
+ ))}
+
+ )
+}
diff --git a/src/store/useApiStore.ts b/src/store/useApiStore.ts
new file mode 100644
index 0000000..007a15c
--- /dev/null
+++ b/src/store/useApiStore.ts
@@ -0,0 +1,76 @@
+import { create } from "zustand"
+import type { TemplateEntry, JobRun } from "../types/api"
+
+function safeGetItem(key: string, fallback: string): string {
+ try {
+ return localStorage.getItem(key) || fallback
+ } catch {
+ return fallback
+ }
+}
+
+function safeSetItem(key: string, value: string) {
+ try {
+ localStorage.setItem(key, value)
+ } catch {
+ // ignore
+ }
+}
+
+export interface ApiStore {
+ serverUrl: string
+ setServerUrl: (url: string) => void
+
+ templates: TemplateEntry[]
+ addTemplate: (t: TemplateEntry) => void
+ removeTemplate: (id: string) => void
+ loadTemplates: (list: TemplateEntry[]) => void
+
+ templateDir: string
+ setTemplateDir: (dir: string) => void
+
+ runs: JobRun[]
+ addRun: (run: JobRun) => void
+ updateRun: (id: string, patch: Partial | ((run: JobRun) => Partial)) => void
+ removeRun: (id: string) => void
+ loadRuns: (list: JobRun[]) => void
+
+ activeRunId: string | null
+ setActiveRunId: (id: string | null) => void
+}
+
+export const useApiStore = create((set, get) => ({
+ serverUrl: safeGetItem("omr_server_url", "http://localhost:8000"),
+ setServerUrl: (url) => {
+ safeSetItem("omr_server_url", url)
+ set({ serverUrl: url })
+ },
+
+ templates: [],
+ addTemplate: (t) => set((s) => ({ templates: [...s.templates, t] })),
+ removeTemplate: (id) => set((s) => ({ templates: s.templates.filter((t) => t.id !== id) })),
+ loadTemplates: (list) => set({ templates: list }),
+
+ templateDir: safeGetItem("omr_template_dir", ""),
+ setTemplateDir: (dir) => {
+ safeSetItem("omr_template_dir", dir)
+ set({ templateDir: dir })
+ },
+
+ runs: [],
+ addRun: (run) => set((s) => ({ runs: [...s.runs, run], activeRunId: run.id })),
+ updateRun: (id, patch) =>
+ set((s) => {
+ const run = s.runs.find((r) => r.id === id)
+ if (!run) return {}
+ const next = typeof patch === "function" ? (patch as (run: JobRun) => Partial)(run) : patch
+ return {
+ runs: s.runs.map((r) => (r.id === id ? { ...r, ...next } : r)),
+ }
+ }),
+ removeRun: (id) => set((s) => ({ runs: s.runs.filter((r) => r.id !== id) })),
+ loadRuns: (list) => set({ runs: list }),
+
+ activeRunId: null,
+ setActiveRunId: (id) => set({ activeRunId: id }),
+}))
diff --git a/src/types/api.ts b/src/types/api.ts
new file mode 100644
index 0000000..cc124e3
--- /dev/null
+++ b/src/types/api.ts
@@ -0,0 +1,56 @@
+export interface ProcessResponse {
+ job_id: string
+ status: string
+ message?: string
+}
+
+export interface JobStatusResponse {
+ job_id: string
+ status: "queued" | "processing" | "completed" | "failed" | "unknown"
+ progress?: string | null
+ result?: Record | null
+ output_files?: string[] | null
+ error_code?: string | null
+ error_message?: string | null
+ created_at?: string | null
+ started_at?: string | null
+ completed_at?: string | null
+}
+
+export interface JobListResponse {
+ jobs: JobStatusResponse[]
+ total: number
+ page: number
+ per_page: number
+}
+
+export interface HealthResponse {
+ status: string
+ version: string
+ redis: string
+ storage: string
+ database: string
+}
+
+export interface TemplateEntry {
+ id: string
+ name: string
+ templatePath: string
+ metadataPath: string
+ bubbleTemplatePath?: string
+ filledTemplatePath?: string
+ createdAt: number
+}
+
+export interface JobRun {
+ id: string
+ templateId: string
+ templateName: string
+ scannedPaths: string[]
+ serverUrl: string
+ mode: "sync" | "async"
+ status: "idle" | "running" | "completed" | "failed"
+ jobs: JobStatusResponse[]
+ createdAt: number
+ completedAt?: number
+}
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts
index e405ed9..f083b72 100644
--- a/src/types/electron.d.ts
+++ b/src/types/electron.d.ts
@@ -7,11 +7,17 @@ export interface ElectronAPI {
isMaximized: () => Promise
onMaximizeChange: (callback: (maximized: boolean) => void) => void
openFileDialog: (filters: { name: string; extensions: string[] }[]) => Promise
+ openDirectoryDialog: () => Promise
saveFileDialog: (defaultName: string, filters: { name: string; extensions: string[] }[]) => Promise
readFile: (path: string) => Promise
writeFile: (path: string, data: string) => Promise
readImage: (path: string) => Promise
writeBinaryFile: (path: string, base64: string) => Promise
+ getAppPath: () => Promise
+ readDir: (path: string) => Promise<{ name: string; isDirectory: boolean }[]>
+ ensureDir: (path: string) => Promise
+ pathExists: (path: string) => Promise
+ copyFile: (src: string, dest: string) => Promise
}
declare global {
diff --git a/src/utils/apiClient.ts b/src/utils/apiClient.ts
new file mode 100644
index 0000000..feac9a6
--- /dev/null
+++ b/src/utils/apiClient.ts
@@ -0,0 +1,75 @@
+import type { ProcessResponse, JobStatusResponse, HealthResponse } from "../types/api"
+
+export async function checkHealth(baseUrl: string): Promise {
+ const res = await fetch(`${baseUrl}/health`, { method: "GET" })
+ if (!res.ok) throw new Error(`Health check failed: ${res.status}`)
+ return res.json()
+}
+
+export async function submitJob(
+ baseUrl: string,
+ files: {
+ template: File
+ metadata: File
+ scanned: File
+ bubbleTemplate?: File | null
+ filledTemplate?: File | null
+ },
+ sync = false,
+): Promise {
+ const form = new FormData()
+ form.append("template", files.template)
+ form.append("metadata", files.metadata)
+ form.append("scanned", files.scanned)
+ if (files.bubbleTemplate) form.append("bubble_template", files.bubbleTemplate)
+ if (files.filledTemplate) form.append("filled_template", files.filledTemplate)
+
+ const endpoint = sync ? "/v1/process/sync" : "/v1/process"
+ const res = await fetch(`${baseUrl}${endpoint}`, {
+ method: "POST",
+ body: form,
+ })
+ if (!res.ok) {
+ const text = await res.text().catch(() => "Unknown error")
+ throw new Error(`API error ${res.status}: ${text}`)
+ }
+ return res.json()
+}
+
+export async function getJobStatus(baseUrl: string, jobId: string): Promise {
+ const res = await fetch(`${baseUrl}/v1/jobs/${jobId}`)
+ if (!res.ok) throw new Error(`Failed to fetch job status: ${res.status}`)
+ return res.json()
+}
+
+export function getOutputUrl(baseUrl: string, jobId: string, filename: string): string {
+ return `${baseUrl}/v1/jobs/${jobId}/output/${encodeURIComponent(filename)}`
+}
+
+export async function listJobs(baseUrl: string, page = 1, perPage = 20): Promise<{ jobs: JobStatusResponse[]; total: number }> {
+ const res = await fetch(`${baseUrl}/v1/jobs?page=${page}&per_page=${perPage}`)
+ if (!res.ok) throw new Error(`Failed to list jobs: ${res.status}`)
+ const data = await res.json()
+ return { jobs: data.jobs ?? [], total: data.total ?? 0 }
+}
+
+export async function deleteJob(baseUrl: string, jobId: string): Promise {
+ const res = await fetch(`${baseUrl}/v1/jobs/${jobId}`, { method: "DELETE" })
+ if (!res.ok) throw new Error(`Failed to delete job: ${res.status}`)
+}
+
+export async function pollUntilComplete(
+ baseUrl: string,
+ jobId: string,
+ onUpdate?: (status: JobStatusResponse) => void,
+ intervalMs = 2000,
+ maxAttempts = 300,
+): Promise {
+ for (let i = 0; i < maxAttempts; i++) {
+ const status = await getJobStatus(baseUrl, jobId)
+ onUpdate?.(status)
+ if (status.status === "completed" || status.status === "failed") return status
+ await new Promise((r) => setTimeout(r, intervalMs))
+ }
+ throw new Error("Polling timed out")
+}
diff --git a/src/utils/templateStorage.ts b/src/utils/templateStorage.ts
new file mode 100644
index 0000000..d8efcde
--- /dev/null
+++ b/src/utils/templateStorage.ts
@@ -0,0 +1,64 @@
+import type { TemplateEntry } from "../types/api"
+
+const TEMPLATES_FILE = "templates.json"
+
+function getTemplatesPath(dir: string) {
+ return `${dir}/${TEMPLATES_FILE}`
+}
+
+export async function loadTemplatesFromDir(dir: string): Promise {
+ if (!window.electronAPI) return []
+ const path = getTemplatesPath(dir)
+ const exists = await window.electronAPI.pathExists(path)
+ if (!exists) return []
+ const content = await window.electronAPI.readFile(path)
+ try {
+ const parsed = JSON.parse(content)
+ if (Array.isArray(parsed)) return parsed as TemplateEntry[]
+ return []
+ } catch {
+ return []
+ }
+}
+
+export async function saveTemplatesToDir(dir: string, templates: TemplateEntry[]): Promise {
+ if (!window.electronAPI) return
+ await window.electronAPI.ensureDir(dir)
+ const path = getTemplatesPath(dir)
+ await window.electronAPI.writeFile(path, JSON.stringify(templates, null, 2))
+}
+
+export async function copyTemplateFiles(
+ templatePath: string,
+ metadataPath: string,
+ destDir: string,
+ name: string,
+ bubbleTemplatePath?: string,
+ filledTemplatePath?: string,
+): Promise<{ templatePath: string; metadataPath: string; bubbleTemplatePath?: string; filledTemplatePath?: string }> {
+ if (!window.electronAPI) throw new Error("Electron API not available")
+ await window.electronAPI.ensureDir(destDir)
+ const safeName = name.replace(/[^a-zA-Z0-9_\-]/g, "_")
+ const tplExt = templatePath.split(".").pop() || "jpg"
+ const destTemplate = `${destDir}/${safeName}_template.${tplExt}`
+ const destMetadata = `${destDir}/${safeName}_metadata.json`
+ await window.electronAPI.copyFile(templatePath, destTemplate)
+ await window.electronAPI.copyFile(metadataPath, destMetadata)
+
+ let destBubbleTemplate: string | undefined
+ let destFilledTemplate: string | undefined
+
+ if (bubbleTemplatePath) {
+ const btExt = bubbleTemplatePath.split(".").pop() || "jpg"
+ destBubbleTemplate = `${destDir}/${safeName}_bubble_template.${btExt}`
+ await window.electronAPI.copyFile(bubbleTemplatePath, destBubbleTemplate)
+ }
+
+ if (filledTemplatePath) {
+ const ftExt = filledTemplatePath.split(".").pop() || "jpg"
+ destFilledTemplate = `${destDir}/${safeName}_filled_template.${ftExt}`
+ await window.electronAPI.copyFile(filledTemplatePath, destFilledTemplate)
+ }
+
+ return { templatePath: destTemplate, metadataPath: destMetadata, bubbleTemplatePath: destBubbleTemplate, filledTemplatePath: destFilledTemplate }
+}