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 && ( +
+ Output Files +
+ {job.output_files.map((file) => ( + + open_in_new + {file} + + ))} +
+
+ )} + + {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 } +}